astroocra commited on
Commit
b31d1f4
·
2 Parent(s): b75dcbd012c21b

Merge HF Spaces demo isolation fix

Browse files

# Conflicts:
# __pycache__/models.cpython-312.pyc
# baseline.py
# deployment/__init__.py
# deployment/client.py
# deployment/models.py
# deployment/openenv.yaml
# deployment/server/__init__.py
# deployment/server/app.py
# deployment/server/deployment_environment.py
# server/__pycache__/__init__.cpython-312.pyc
# server/__pycache__/deployment_environment.cpython-312.pyc

.github/workflows/test.yml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Test & Validate Submission
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ validate:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.10", "3.11"]
11
+
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+
15
+ - name: Set up Python ${{ matrix.python-version }}
16
+ uses: actions/setup-python@v4
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+
20
+ - name: Install dependencies
21
+ run: |
22
+ python -m pip install --upgrade pip
23
+ pip install -e .
24
+ pip install pytest pytest-asyncio ruff mypy
25
+
26
+ - name: Lint with ruff
27
+ run: ruff check . --select=E,W,F
28
+
29
+ - name: Type check with mypy
30
+ run: |
31
+ mypy server/ inference.py baseline.py --ignore-missing-imports
32
+
33
+ - name: Test environment loads
34
+ run: |
35
+ python -c "from server.env import EcoLLMInferenceRoutingEnvironment; env = EcoLLMInferenceRoutingEnvironment(); print('Environment loads')"
36
+
37
+ - name: Test baseline agents
38
+ run: |
39
+ python -c "from baseline import RandomRoutingAgent; agent = RandomRoutingAgent(); print('Random agent loads')"
40
+ python -c "from baseline import HeuristicRoutingAgent; agent = HeuristicRoutingAgent(); print('Heuristic agent loads')"
41
+
42
+ - name: Test Docker build
43
+ run: |
44
+ docker build -t eco-llm-routing -f deployment/Dockerfile .
45
+ continue-on-error: true
deployment/Dockerfile → Dockerfile RENAMED
@@ -70,11 +70,12 @@ ENV PATH="/app/.venv/bin:$PATH"
70
 
71
  # Set PYTHONPATH so imports work correctly
72
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
 
73
 
74
  # Health check
75
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
- CMD curl -f http://localhost:8000/health || exit 1
77
 
78
  # Run the FastAPI server
79
  # The module path is constructed to work with the /app/env structure
80
- CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
 
70
 
71
  # Set PYTHONPATH so imports work correctly
72
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+ ENV ENABLE_WEB_INTERFACE=true
74
 
75
  # Health check
76
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
77
+ CMD curl -f http://localhost:7860/ || exit 1
78
 
79
  # Run the FastAPI server
80
  # The module path is constructed to work with the /app/env structure
81
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 7860"]
README.md ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Eco-LLM Inference Routing Environment
2
+
3
+ > An [OpenEnv](https://github.com/meta-pytorch/OpenEnv)-compatible reinforcement learning environment for carbon-aware, multi-objective LLM query routing.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ This environment simulates the problem of routing incoming LLM queries to the most appropriate model tier (small, medium, or large) while jointly optimising for **accuracy**, **energy efficiency**, **latency**, and **carbon footprint**.
10
+
11
+ At each step, an agent observes a query, the current carbon intensity of the grid, and the contents of a response cache. It then selects a routing *strategy* and a target *model*, receiving a structured reward signal that reflects all four objectives.
12
+
13
+ The environment is fully deterministic — given the same actions, every episode plays out identically — making it suitable for reproducible evaluation and grading.
14
+
15
+ ---
16
+
17
+ ## Environment Design
18
+
19
+ ### Action Space
20
+
21
+ Each action is an `RLAction` with three fields:
22
+
23
+ | Field | Type | Description |
24
+ |---|---|---|
25
+ | `strategy` | `Strategy` | How to handle the query (see below) |
26
+ | `model_choice` | `ModelChoice` | Target model: `SMALL`, `MEDIUM`, or `LARGE` |
27
+ | `exit_flag` | `bool` | Whether to terminate a cascade early on first correct answer |
28
+
29
+ **Available strategies:**
30
+
31
+ | Strategy | Behaviour |
32
+ |---|---|
33
+ | `NONE` | Direct inference with the chosen model |
34
+ | `USE_CACHE` | Serve from cache if this query was answered correctly before |
35
+ | `DO_CASCADE` | Try models from smallest up to the chosen model, stopping on `exit_flag` |
36
+ | `EARLY_EXIT` | Infer with chosen model; bonus reward if correct and stops early |
37
+ | `CALL_KB` | Look up a knowledge base; only succeeds if `kb_available` for the query |
38
+ | `WAIT` | Skip this step to wait for lower carbon intensity |
39
+
40
+ ### Observation Space
41
+
42
+ Each `RLObservation` contains:
43
+
44
+ | Field | Description |
45
+ |---|---|
46
+ | `query` | The current query text |
47
+ | `carbon_intensity` | Grid carbon intensity this step (0.0 = clean, 1.0 = dirty) |
48
+ | `cache_contents` | List of queries already answered correctly (available for free re-use) |
49
+ | `correct_answer` | Ground-truth answer (used for grading) |
50
+ | `reward_details` | Full structured `RLReward` breakdown |
51
+ | `done` | Whether the episode has ended |
52
+
53
+ ### Reward Function
54
+
55
+ ```
56
+ reward = score − carbon_penalty − latency_penalty + bonuses
57
+ ```
58
+
59
+ | Component | Formula | Notes |
60
+ |---|---|---|
61
+ | `score` | `1.0` if correct, else `0.0` | Primary accuracy signal |
62
+ | `carbon_penalty` | `carbon_intensity × energy_cost` | Penalises dirty-grid inference |
63
+ | `latency_penalty` | `0.1 × latency_cost` | Soft latency pressure |
64
+ | `bonuses` | Cache hit: `+0.5`, Early exit: `+0.1` | Efficiency rewards |
65
+ | Task penalty | Configurable per task (e.g. `−0.2` for LARGE on task 2) | Discourages over-provisioning |
66
+
67
+ **Energy and latency costs per model:**
68
+
69
+ | Model | Energy cost | Latency cost |
70
+ |---|---|---|
71
+ | `SMALL` | 0.1 | 1.0 |
72
+ | `MEDIUM` | 0.3 | 2.0 |
73
+ | `LARGE` | 0.6 | 5.0 |
74
+
75
+ ### Tasks
76
+
77
+ Three tasks of increasing difficulty are included:
78
+
79
+ | Task | Difficulty | Queries | Description |
80
+ |---|---|---|---|
81
+ | `task_1` | Easy | 1 | Single-query routing; accuracy emphasis |
82
+ | `task_2` | Medium | 3 | Multi-query episode; LARGE model penalised |
83
+ | `task_3` | Hard | 5 | Stateful episode with repeated queries, caching, cascading, KB lookups, and carbon-aware waiting |
84
+
85
+ ---
86
+
87
+ ## Project Structure
88
+
89
+ ```
90
+ eco_llm_inference_routing/
91
+ ├── openenv.yaml # OpenEnv spec manifest
92
+ ├── pyproject.toml # Python package and dependency config
93
+ ├── uv.lock # Locked dependency tree
94
+ ├── baseline.py # Baseline agents and CLI runner
95
+ ├── server/
96
+ │ ├── app.py # FastAPI app (OpenEnv HTTP server)
97
+ │ ├── env.py # EcoLLMInferenceRoutingEnvironment (core logic)
98
+ │ ├── models.py # RLAction, RLObservation, RLState, RLReward
99
+ │ └── tasks.py # Task definitions, query specs, reward constants
100
+ └── deployment/
101
+ └── Dockerfile # Multi-stage build from openenv-base
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Setup
107
+
108
+ ### Prerequisites
109
+
110
+ - Python 3.10+
111
+ - [`uv`](https://github.com/astral-sh/uv) (recommended) or `pip`
112
+ - Docker (for containerised deployment)
113
+
114
+ ### Install dependencies
115
+
116
+ ```bash
117
+ # With uv (recommended)
118
+ uv sync
119
+
120
+ # With pip
121
+ pip install -e .
122
+ ```
123
+
124
+ ### Run the server locally
125
+
126
+ ```bash
127
+ python -m server.app
128
+ # or
129
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
130
+ ```
131
+
132
+ The server exposes OpenEnv-standard endpoints at `http://localhost:8000`:
133
+ - `POST /reset` — start a new episode
134
+ - `POST /step` — take an action
135
+ - `GET /state` — inspect current state
136
+
137
+ ---
138
+
139
+ ## Running the Inference Script
140
+
141
+ The submission-ready inference script is `inference.py` in the repo root. It uses the OpenAI-compatible client and emits mandatory `[START]`/`[STEP]`/`[END]` logs.
142
+
143
+ ```bash
144
+ # Required environment variables
145
+ export HF_TOKEN=your_hf_token
146
+ export API_BASE_URL=https://router.huggingface.co/v1
147
+ export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
148
+ export ECO_LLM_TASK=task_1 # task_1 | task_2 | task_3
149
+
150
+ python inference.py
151
+ ```
152
+
153
+ **Example output:**
154
+ ```
155
+ [START] task=task_1 env=eco_llm_inference_routing model=Qwen/Qwen2.5-72B-Instruct
156
+ [STEP] step=1 action=strategy=NONE,model=MEDIUM,exit=false reward=0.68 done=true error=null
157
+ [END] success=true steps=1 rewards=0.68
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Running Baselines
163
+
164
+ The `baseline.py` script provides three independent baseline agents for evaluation:
165
+
166
+ ### Quick Start
167
+
168
+ ```bash
169
+ # Evaluate single agent
170
+ python baseline.py evaluate --agent random --task task_3 --episodes 10
171
+ python baseline.py evaluate --agent heuristic --task task_3 --episodes 10
172
+ python baseline.py evaluate --agent llm --task task_3 --episodes 10 \
173
+ --api-key $HF_TOKEN --model-name Qwen/Qwen2.5-72B-Instruct
174
+
175
+ # Compare all agents on same task
176
+ python baseline.py compare --task task_3 --episodes 20
177
+ ```
178
+
179
+ ### Agent Types
180
+
181
+ #### 1. Random Agent (`--agent random`)
182
+
183
+ - Policy: Uniformly random strategy + model selection
184
+ - Purpose: Lower-bound baseline (no intelligence)
185
+ - Use Case: Verify environment is non-trivial
186
+ - Expected Performance: ~0.8-1.0 reward (`task_3`)
187
+
188
+ #### 2. Heuristic Agent (`--agent heuristic`)
189
+
190
+ - Policy: Rule-based routing
191
+ - Check cache first (free re-use)
192
+ - Try KB if available
193
+ - Wait if carbon > 0.7
194
+ - Cascade on hard queries
195
+ - Purpose: Mid-range baseline (no LLM calls)
196
+ - Use Case: Demonstrate strategic routing matters
197
+ - Expected Performance: ~2.0-2.3 reward (`task_3`)
198
+
199
+ #### 3. LLM Agent (`--agent llm`)
200
+
201
+ - Policy: Intelligent routing via Claude/GPT/Qwen
202
+ - Purpose: Upper-bound baseline (intelligent agent)
203
+ - Use Case: Show LLM can optimize trade-offs
204
+ - Requirements: Valid `HF_TOKEN` or `OPENAI_API_KEY`
205
+ - Expected Performance: ~2.2-2.5 reward (`task_3`)
206
+
207
+ ### Baseline Comparison
208
+
209
+ ```bash
210
+ $ python baseline.py compare --task task_3 --episodes 20
211
+
212
+ ======================================================================
213
+ Eco-LLM Baseline: COMPARISON
214
+ Task: task_3 | Episodes: 20
215
+ ======================================================================
216
+
217
+ Agent Mean Std Dev Min Max
218
+ ----------------------------------------------------------------------
219
+ Random 0.8450 0.6200 0.0000 2.1000
220
+ Heuristic 2.1400 0.1800 1.9000 2.5000
221
+ LLM 2.3100 0.2500 1.8000 2.8000
222
+
223
+ Relative Performance:
224
+ Heuristic > Random: 2.53x
225
+ LLM > Heuristic: 1.08x
226
+ ======================================================================
227
+ ```
228
+
229
+ ### Running on Different Tasks
230
+
231
+ ```bash
232
+ # Easy task (1 query)
233
+ python baseline.py compare --task task_1 --episodes 50
234
+
235
+ # Medium task (3 queries)
236
+ python baseline.py compare --task task_2 --episodes 30
237
+
238
+ # Hard task (5 queries, stateful)
239
+ python baseline.py compare --task task_3 --episodes 20
240
+ ```
241
+
242
+ ### API Key Configuration
243
+
244
+ The LLM agent requires an API key. Set one of:
245
+
246
+ ```bash
247
+ # Hugging Face (recommended for Qwen, Llama)
248
+ export HF_TOKEN='hf_xxxxxxxxxxxxx'
249
+
250
+ # OpenAI (for GPT models)
251
+ export OPENAI_API_KEY='sk-xxxxxxxxxxxxx'
252
+ ```
253
+
254
+ Specify model:
255
+
256
+ ```bash
257
+ python baseline.py evaluate --agent llm --task task_3 \
258
+ --model-name Qwen/Qwen2.5-72B-Instruct
259
+ ```
260
+
261
+ ### Expected Results Summary
262
+
263
+ | Task | Random | Heuristic | LLM |
264
+ |---|---:|---:|---:|
265
+ | `task_1` (Easy) | 0.50 | 1.50 | 1.45 |
266
+ | `task_2` (Medium) | 1.20 | 2.00 | 2.15 |
267
+ | `task_3` (Hard) | 0.85 | 2.14 | 2.31 |
268
+
269
+ Key insights:
270
+
271
+ - Random agent struggles because it has no strategy.
272
+ - Heuristic agent uses cache, KB, wait, and cascade effectively.
273
+ - LLM agent adds a modest improvement through better trade-off decisions.
274
+ - The environment demonstrates multi-objective routing trade-offs clearly.
275
+
276
+ ---
277
+
278
+ ## Docker Deployment
279
+
280
+ Build and run the environment as a container:
281
+
282
+ ```bash
283
+ # Build
284
+ docker build -t eco-llm-routing -f deployment/Dockerfile .
285
+
286
+ # Run
287
+ docker run -p 8000:8000 eco-llm-routing
288
+ ```
289
+
290
+ The container serves the OpenEnv HTTP interface at port 8000 and includes a health check at `GET /health`.
291
+
292
+ To deploy to Hugging Face Spaces via the OpenEnv CLI:
293
+
294
+ ```bash
295
+ pip install openenv-core
296
+ openenv push --repo-id your-org/eco-llm-routing
297
+ ```
298
+
299
+ ---
300
+
301
+ ## OpenEnv Compliance
302
+
303
+ This environment conforms to the [OpenEnv spec](https://github.com/meta-pytorch/OpenEnv):
304
+
305
+ - `openenv.yaml` with `spec_version: 1`, `type: space`, `runtime: fastapi`
306
+ - `EcoLLMInferenceRoutingEnvironment` extends `openenv.core.env_server.interfaces.Environment`
307
+ - Typed `Action`, `Observation`, and `State` via Pydantic models
308
+ - Server bootstrapped with `create_app(...)` from `openenv.core.env_server.http_server`
309
+ - Multi-stage Dockerfile based on `ghcr.io/meta-pytorch/openenv-base`
310
+
311
+ ---
312
+
313
+ ## License
314
+
315
+ BSD-style license — see [LICENSE](./LICENSE) for full terms.
316
+ Copyright (c) Meta Platforms, Inc. and affiliates.
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Eco-LLM inference routing OpenEnv package."""
__pycache__/baseline.cpython-312.pyc ADDED
Binary file (17 kB). View file
 
baseline.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ baseline.py - Eco-LLM Baseline Agents
3
+ =====================================
4
+ Three independent baseline agents for evaluation and comparison.
5
+
6
+ Usage:
7
+ python baseline.py evaluate --agent random --task task_1 --episodes 10
8
+ python baseline.py evaluate --agent heuristic --task task_3 --episodes 10
9
+ python baseline.py compare --task task_3 --episodes 20
10
+
11
+ Expected Results (task_3):
12
+ Random agent: ~0.85 +/- 0.62
13
+ Heuristic agent: ~2.14 +/- 0.18
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import textwrap
23
+ from abc import ABC, abstractmethod
24
+ from typing import Optional
25
+
26
+ import numpy as np
27
+ from openai import OpenAI
28
+
29
+ try:
30
+ from server.env import EcoLLMInferenceRoutingEnvironment
31
+ from server.models import ModelChoice, RLAction, RLObservation, Strategy
32
+ except ImportError as exc:
33
+ print(f"ERROR: Cannot import server modules: {exc}")
34
+ print("Make sure you're in the repo root directory.")
35
+ sys.exit(1)
36
+
37
+
38
+ class BaseRoutingAgent(ABC):
39
+ """Abstract base class for routing agents."""
40
+
41
+ def __init__(self, name: str = "BaseAgent") -> None:
42
+ self.name = name
43
+
44
+ @abstractmethod
45
+ def get_action(self, observation: RLObservation) -> RLAction:
46
+ """Given an observation, return the next action."""
47
+
48
+
49
+ class RandomRoutingAgent(BaseRoutingAgent):
50
+ """
51
+ Random baseline agent.
52
+
53
+ Policy: Uniformly random strategy + model selection.
54
+ Purpose: Lower-bound baseline (no intelligence).
55
+ """
56
+
57
+ def __init__(self, seed: int = 42) -> None:
58
+ super().__init__(name="RandomRoutingAgent")
59
+ self.rng = np.random.RandomState(seed)
60
+
61
+ def get_action(self, observation: RLObservation) -> RLAction:
62
+ """Select a random strategy and model."""
63
+ del observation
64
+ strategies = list(Strategy)
65
+ models = list(ModelChoice)
66
+ strategy = strategies[int(self.rng.randint(len(strategies)))]
67
+ model_choice = models[int(self.rng.randint(len(models)))]
68
+ exit_flag = bool(self.rng.random() > 0.5)
69
+ return RLAction(strategy=strategy, model_choice=model_choice, exit_flag=exit_flag)
70
+
71
+
72
+ class HeuristicRoutingAgent(BaseRoutingAgent):
73
+ """
74
+ Heuristic baseline agent.
75
+
76
+ Policy: Rule-based routing with priorities.
77
+ Priority 1: Use cache
78
+ Priority 2: Wait if carbon > 0.7
79
+ Priority 3: Cascade with early exit
80
+ """
81
+
82
+ def __init__(self) -> None:
83
+ super().__init__(name="HeuristicRoutingAgent")
84
+
85
+ def get_action(self, observation: RLObservation) -> RLAction:
86
+ """Apply heuristic routing policy."""
87
+ query = observation.query
88
+ carbon = observation.carbon_intensity
89
+ cache = observation.cache_contents
90
+
91
+ if query in cache:
92
+ return RLAction(
93
+ strategy=Strategy.USE_CACHE,
94
+ model_choice=ModelChoice.SMALL,
95
+ exit_flag=False,
96
+ )
97
+
98
+ if carbon > 0.7:
99
+ return RLAction(
100
+ strategy=Strategy.WAIT,
101
+ model_choice=ModelChoice.SMALL,
102
+ exit_flag=False,
103
+ )
104
+
105
+ return RLAction(
106
+ strategy=Strategy.DO_CASCADE,
107
+ model_choice=ModelChoice.LARGE,
108
+ exit_flag=True,
109
+ )
110
+
111
+
112
+ class LLMRoutingAgent(BaseRoutingAgent):
113
+ """
114
+ LLM-based baseline agent.
115
+
116
+ Policy: Uses an LLM to make routing decisions.
117
+ Purpose: Upper-bound baseline (intelligent agent).
118
+ """
119
+
120
+ SYSTEM_PROMPT = textwrap.dedent(
121
+ """
122
+ You are an expert LLM inference router.
123
+ Optimize for accuracy, energy efficiency, carbon awareness, and latency.
124
+
125
+ Available strategies: NONE, USE_CACHE, DO_CASCADE, EARLY_EXIT, WAIT, CALL_KB
126
+ Models: SMALL, MEDIUM, LARGE
127
+
128
+ Respond with ONLY valid JSON:
129
+ {"strategy": "...", "model_choice": "...", "exit_flag": true/false}
130
+ """
131
+ ).strip()
132
+
133
+ def __init__(self, api_key: Optional[str] = None, model_name: str = "gpt-4-mini") -> None:
134
+ super().__init__(name="LLMRoutingAgent")
135
+ resolved_api_key = api_key or os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY")
136
+ if not resolved_api_key:
137
+ raise ValueError("LLM agent requires HF_TOKEN or OPENAI_API_KEY")
138
+
139
+ self.client = OpenAI(
140
+ base_url=os.getenv("API_BASE_URL", "https://router.huggingface.co/v1"),
141
+ api_key=resolved_api_key,
142
+ )
143
+ self.model_name = model_name
144
+
145
+ def get_action(self, observation: RLObservation) -> RLAction:
146
+ """Query the LLM for a routing decision."""
147
+ try:
148
+ query = observation.query
149
+ carbon = observation.carbon_intensity
150
+ cache = json.dumps(observation.cache_contents) if observation.cache_contents else "[]"
151
+
152
+ prompt = f"""
153
+ Query: {query!r}
154
+ Carbon intensity: {carbon:.2f}
155
+ Cache: {cache}
156
+
157
+ Choose the best routing strategy and model.
158
+ """.strip()
159
+
160
+ response = self.client.chat.completions.create(
161
+ model=self.model_name,
162
+ messages=[
163
+ {"role": "system", "content": self.SYSTEM_PROMPT},
164
+ {"role": "user", "content": prompt},
165
+ ],
166
+ temperature=0.2,
167
+ max_tokens=100,
168
+ timeout=10.0,
169
+ )
170
+
171
+ raw = (response.choices[0].message.content or "").strip()
172
+ raw = raw.replace("```json", "").replace("```", "").strip()
173
+ parsed = json.loads(raw)
174
+
175
+ strategy = Strategy[parsed.get("strategy", "NONE").upper()]
176
+ model_choice = ModelChoice[parsed.get("model_choice", "SMALL").upper()]
177
+ exit_flag = bool(parsed.get("exit_flag", False))
178
+ return RLAction(strategy=strategy, model_choice=model_choice, exit_flag=exit_flag)
179
+
180
+ except Exception as exc:
181
+ print(f"[DEBUG] LLM error: {exc}, using heuristic", flush=True)
182
+ heuristic = HeuristicRoutingAgent()
183
+ return heuristic.get_action(observation)
184
+
185
+
186
+ def evaluate_agent(
187
+ agent: BaseRoutingAgent,
188
+ task_id: str,
189
+ num_episodes: int = 10,
190
+ verbose: bool = True,
191
+ ) -> dict:
192
+ """Run an agent on a task for multiple episodes."""
193
+ env = EcoLLMInferenceRoutingEnvironment()
194
+ rewards_list: list[float] = []
195
+
196
+ for episode in range(num_episodes):
197
+ try:
198
+ obs = env.reset(task_id=task_id)
199
+ episode_reward = 0.0
200
+
201
+ while not obs.done:
202
+ action = agent.get_action(obs)
203
+ obs = env.step(action)
204
+ episode_reward += obs.reward_details.total_reward
205
+
206
+ rewards_list.append(episode_reward)
207
+ if verbose:
208
+ print(f" Episode {episode + 1:2d}/{num_episodes}: reward={episode_reward:.4f}", flush=True)
209
+
210
+ except Exception as exc:
211
+ print(f" Episode {episode + 1:2d}/{num_episodes}: ERROR - {str(exc)[:80]}", flush=True)
212
+ rewards_list.append(0.0)
213
+
214
+ rewards_array = np.array(rewards_list)
215
+ return {
216
+ "mean": float(np.mean(rewards_array)),
217
+ "std": float(np.std(rewards_array)),
218
+ "min": float(np.min(rewards_array)),
219
+ "max": float(np.max(rewards_array)),
220
+ "rewards": rewards_list,
221
+ }
222
+
223
+
224
+ def main() -> None:
225
+ """Command-line interface for baseline evaluation."""
226
+ parser = argparse.ArgumentParser(
227
+ description="Eco-LLM Baseline Agent Evaluation",
228
+ formatter_class=argparse.RawDescriptionHelpFormatter,
229
+ epilog="""
230
+ Examples:
231
+ python baseline.py evaluate --agent random --task task_1 --episodes 10
232
+ python baseline.py evaluate --agent heuristic --task task_3 --episodes 10
233
+ python baseline.py evaluate --agent llm --task task_3 --episodes 10 --api-key $HF_TOKEN
234
+ python baseline.py compare --task task_3 --episodes 20
235
+ """,
236
+ )
237
+
238
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
239
+
240
+ evaluate_parser = subparsers.add_parser("evaluate", help="Evaluate a single agent")
241
+ evaluate_parser.add_argument(
242
+ "--agent",
243
+ choices=["random", "heuristic", "llm"],
244
+ default="heuristic",
245
+ help="Agent type",
246
+ )
247
+ evaluate_parser.add_argument(
248
+ "--task",
249
+ choices=["task_1", "task_2", "task_3"],
250
+ default="task_1",
251
+ help="Task to evaluate on",
252
+ )
253
+ evaluate_parser.add_argument(
254
+ "--episodes",
255
+ type=int,
256
+ default=10,
257
+ help="Number of episodes",
258
+ )
259
+ evaluate_parser.add_argument(
260
+ "--api-key",
261
+ default=None,
262
+ help="API key for LLM agent (defaults to HF_TOKEN)",
263
+ )
264
+ evaluate_parser.add_argument(
265
+ "--model-name",
266
+ default="gpt-4-mini",
267
+ help="LLM model name",
268
+ )
269
+
270
+ compare_parser = subparsers.add_parser("compare", help="Compare all agents")
271
+ compare_parser.add_argument(
272
+ "--task",
273
+ choices=["task_1", "task_2", "task_3"],
274
+ default="task_1",
275
+ help="Task to evaluate on",
276
+ )
277
+ compare_parser.add_argument(
278
+ "--episodes",
279
+ type=int,
280
+ default=10,
281
+ help="Episodes per agent",
282
+ )
283
+ compare_parser.add_argument(
284
+ "--api-key",
285
+ default=None,
286
+ help="API key for LLM agent (defaults to HF_TOKEN)",
287
+ )
288
+ compare_parser.add_argument(
289
+ "--model-name",
290
+ default="gpt-4-mini",
291
+ help="LLM model name",
292
+ )
293
+
294
+ args = parser.parse_args()
295
+
296
+ if args.command == "evaluate":
297
+ print(f"\n{'=' * 70}")
298
+ print(" Eco-LLM Baseline Evaluation")
299
+ print(f" Agent: {args.agent.upper()}")
300
+ print(f" Task: {args.task} | Episodes: {args.episodes}")
301
+ print(f"{'=' * 70}\n")
302
+
303
+ if args.agent == "random":
304
+ agent = RandomRoutingAgent()
305
+ elif args.agent == "heuristic":
306
+ agent = HeuristicRoutingAgent()
307
+ else:
308
+ try:
309
+ agent = LLMRoutingAgent(api_key=args.api_key, model_name=args.model_name)
310
+ except ValueError as exc:
311
+ print(f"ERROR: {exc}")
312
+ sys.exit(1)
313
+
314
+ results = evaluate_agent(agent, args.task, args.episodes)
315
+
316
+ print(f"\n{'=' * 70}")
317
+ print(f" Results: {agent.name}")
318
+ print(f"{'=' * 70}")
319
+ print(f" Mean : {results['mean']:.4f}")
320
+ print(f" Std Dev: {results['std']:.4f}")
321
+ print(f" Min : {results['min']:.4f}")
322
+ print(f" Max : {results['max']:.4f}")
323
+ print(f"{'=' * 70}\n")
324
+
325
+ elif args.command == "compare":
326
+ print(f"\n{'=' * 70}")
327
+ print(" Eco-LLM Baseline Comparison")
328
+ print(f" Task: {args.task} | Episodes: {args.episodes}")
329
+ print(f"{'=' * 70}\n")
330
+
331
+ results_dict: dict[str, dict] = {}
332
+
333
+ print("Running Random Agent...")
334
+ agent_random = RandomRoutingAgent()
335
+ results_dict["Random"] = evaluate_agent(agent_random, args.task, args.episodes, verbose=False)
336
+
337
+ print("\nRunning Heuristic Agent...")
338
+ agent_heuristic = HeuristicRoutingAgent()
339
+ results_dict["Heuristic"] = evaluate_agent(
340
+ agent_heuristic,
341
+ args.task,
342
+ args.episodes,
343
+ verbose=False,
344
+ )
345
+
346
+ try:
347
+ print("\nRunning LLM Agent...")
348
+ agent_llm = LLMRoutingAgent(api_key=args.api_key, model_name=args.model_name)
349
+ results_dict["LLM"] = evaluate_agent(agent_llm, args.task, args.episodes, verbose=False)
350
+ except ValueError:
351
+ print("Skipping LLM Agent (no API key available)")
352
+
353
+ print(f"\n{'=' * 70}")
354
+ print(" Results Comparison")
355
+ print(f"{'=' * 70}\n")
356
+ print(f"{'Agent':<15} {'Mean':<12} {'Std Dev':<12} {'Min':<12} {'Max':<12}")
357
+ print(f"{'-' * 70}")
358
+
359
+ for agent_name, results in results_dict.items():
360
+ print(
361
+ f"{agent_name:<15} {results['mean']:<12.4f} "
362
+ f"{results['std']:<12.4f} {results['min']:<12.4f} {results['max']:<12.4f}"
363
+ )
364
+
365
+ print(f"\n{'Relative Performance':<15}")
366
+ print(f"{'-' * 70}")
367
+ if "Heuristic" in results_dict and "Random" in results_dict:
368
+ ratio = results_dict["Heuristic"]["mean"] / (results_dict["Random"]["mean"] + 1e-6)
369
+ print(f" Heuristic > Random: {ratio:.2f}x")
370
+ if "LLM" in results_dict and "Heuristic" in results_dict:
371
+ ratio = results_dict["LLM"]["mean"] / (results_dict["Heuristic"]["mean"] + 1e-6)
372
+ print(f" LLM > Heuristic: {ratio:.2f}x")
373
+
374
+ print(f"{'=' * 70}\n")
375
+
376
+ else:
377
+ parser.print_help()
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
deployment/README.md DELETED
@@ -1,255 +0,0 @@
1
- ---
2
- title: Deployment Environment Server
3
- emoji: 🖥️
4
- colorFrom: pink
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- app_port: 8000
9
- base_path: /web
10
- tags:
11
- - openenv
12
- ---
13
-
14
- # Deployment Environment
15
-
16
- A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
17
-
18
- ## Quick Start
19
-
20
- The simplest way to use the Deployment environment is through the `DeploymentEnv` class:
21
-
22
- ```python
23
- from deployment import DeploymentAction, DeploymentEnv
24
-
25
- try:
26
- # Create environment from Docker image
27
- deploymentenv = DeploymentEnv.from_docker_image("deployment-env:latest")
28
-
29
- # Reset
30
- result = deploymentenv.reset()
31
- print(f"Reset: {result.observation.echoed_message}")
32
-
33
- # Send multiple messages
34
- messages = ["Hello, World!", "Testing echo", "Final message"]
35
-
36
- for msg in messages:
37
- result = deploymentenv.step(DeploymentAction(message=msg))
38
- print(f"Sent: '{msg}'")
39
- print(f" → Echoed: '{result.observation.echoed_message}'")
40
- print(f" → Length: {result.observation.message_length}")
41
- print(f" → Reward: {result.reward}")
42
-
43
- finally:
44
- # Always clean up
45
- deploymentenv.close()
46
- ```
47
-
48
- That's it! The `DeploymentEnv.from_docker_image()` method handles:
49
- - Starting the Docker container
50
- - Waiting for the server to be ready
51
- - Connecting to the environment
52
- - Container cleanup when you call `close()`
53
-
54
- ## Building the Docker Image
55
-
56
- Before using the environment, you need to build the Docker image:
57
-
58
- ```bash
59
- # From project root
60
- docker build -t deployment-env:latest -f server/Dockerfile .
61
- ```
62
-
63
- ## Deploying to Hugging Face Spaces
64
-
65
- You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
66
-
67
- ```bash
68
- # From the environment directory (where openenv.yaml is located)
69
- openenv push
70
-
71
- # Or specify options
72
- openenv push --namespace my-org --private
73
- ```
74
-
75
- The `openenv push` command will:
76
- 1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
77
- 2. Prepare a custom build for Hugging Face Docker space (enables web interface)
78
- 3. Upload to Hugging Face (ensuring you're logged in)
79
-
80
- ### Prerequisites
81
-
82
- - Authenticate with Hugging Face: The command will prompt for login if not already authenticated
83
-
84
- ### Options
85
-
86
- - `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
87
- - `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
88
- - `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
89
- - `--private`: Deploy the space as private (default: public)
90
-
91
- ### Examples
92
-
93
- ```bash
94
- # Push to your personal namespace (defaults to username/env-name from openenv.yaml)
95
- openenv push
96
-
97
- # Push to a specific repository
98
- openenv push --repo-id my-org/my-env
99
-
100
- # Push with a custom base image
101
- openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
102
-
103
- # Push as a private space
104
- openenv push --private
105
-
106
- # Combine options
107
- openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
108
- ```
109
-
110
- After deployment, your space will be available at:
111
- `https://huggingface.co/spaces/<repo-id>`
112
-
113
- The deployed space includes:
114
- - **Web Interface** at `/web` - Interactive UI for exploring the environment
115
- - **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
116
- - **Health Check** at `/health` - Container health monitoring
117
- - **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
118
-
119
- ## Environment Details
120
-
121
- ### Action
122
- **DeploymentAction**: Contains a single field
123
- - `message` (str) - The message to echo back
124
-
125
- ### Observation
126
- **DeploymentObservation**: Contains the echo response and metadata
127
- - `echoed_message` (str) - The message echoed back
128
- - `message_length` (int) - Length of the message
129
- - `reward` (float) - Reward based on message length (length × 0.1)
130
- - `done` (bool) - Always False for echo environment
131
- - `metadata` (dict) - Additional info like step count
132
-
133
- ### Reward
134
- The reward is calculated as: `message_length × 0.1`
135
- - "Hi" → reward: 0.2
136
- - "Hello, World!" → reward: 1.3
137
- - Empty message → reward: 0.0
138
-
139
- ## Advanced Usage
140
-
141
- ### Connecting to an Existing Server
142
-
143
- If you already have a Deployment environment server running, you can connect directly:
144
-
145
- ```python
146
- from deployment import DeploymentEnv
147
-
148
- # Connect to existing server
149
- deploymentenv = DeploymentEnv(base_url="<ENV_HTTP_URL_HERE>")
150
-
151
- # Use as normal
152
- result = deploymentenv.reset()
153
- result = deploymentenv.step(DeploymentAction(message="Hello!"))
154
- ```
155
-
156
- Note: When connecting to an existing server, `deploymentenv.close()` will NOT stop the server.
157
-
158
- ### Using the Context Manager
159
-
160
- The client supports context manager usage for automatic connection management:
161
-
162
- ```python
163
- from deployment import DeploymentAction, DeploymentEnv
164
-
165
- # Connect with context manager (auto-connects and closes)
166
- with DeploymentEnv(base_url="http://localhost:8000") as env:
167
- result = env.reset()
168
- print(f"Reset: {result.observation.echoed_message}")
169
- # Multiple steps with low latency
170
- for msg in ["Hello", "World", "!"]:
171
- result = env.step(DeploymentAction(message=msg))
172
- print(f"Echoed: {result.observation.echoed_message}")
173
- ```
174
-
175
- The client uses WebSocket connections for:
176
- - **Lower latency**: No HTTP connection overhead per request
177
- - **Persistent session**: Server maintains your environment state
178
- - **Efficient for episodes**: Better for many sequential steps
179
-
180
- ### Concurrent WebSocket Sessions
181
-
182
- The server supports multiple concurrent WebSocket connections. To enable this,
183
- modify `server/app.py` to use factory mode:
184
-
185
- ```python
186
- # In server/app.py - use factory mode for concurrent sessions
187
- app = create_app(
188
- DeploymentEnvironment, # Pass class, not instance
189
- DeploymentAction,
190
- DeploymentObservation,
191
- max_concurrent_envs=4, # Allow 4 concurrent sessions
192
- )
193
- ```
194
-
195
- Then multiple clients can connect simultaneously:
196
-
197
- ```python
198
- from deployment import DeploymentAction, DeploymentEnv
199
- from concurrent.futures import ThreadPoolExecutor
200
-
201
- def run_episode(client_id: int):
202
- with DeploymentEnv(base_url="http://localhost:8000") as env:
203
- result = env.reset()
204
- for i in range(10):
205
- result = env.step(DeploymentAction(message=f"Client {client_id}, step {i}"))
206
- return client_id, result.observation.message_length
207
-
208
- # Run 4 episodes concurrently
209
- with ThreadPoolExecutor(max_workers=4) as executor:
210
- results = list(executor.map(run_episode, range(4)))
211
- ```
212
-
213
- ## Development & Testing
214
-
215
- ### Direct Environment Testing
216
-
217
- Test the environment logic directly without starting the HTTP server:
218
-
219
- ```bash
220
- # From the server directory
221
- python3 server/deployment_environment.py
222
- ```
223
-
224
- This verifies that:
225
- - Environment resets correctly
226
- - Step executes actions properly
227
- - State tracking works
228
- - Rewards are calculated correctly
229
-
230
- ### Running Locally
231
-
232
- Run the server locally for development:
233
-
234
- ```bash
235
- uvicorn server.app:app --reload
236
- ```
237
-
238
- ## Project Structure
239
-
240
- ```
241
- deployment/
242
- ├── .dockerignore # Docker build exclusions
243
- ├── __init__.py # Module exports
244
- ├── README.md # This file
245
- ├── openenv.yaml # OpenEnv manifest
246
- ├── pyproject.toml # Project metadata and dependencies
247
- ├── uv.lock # Locked dependencies (generated)
248
- ├── client.py # DeploymentEnv client
249
- ├── models.py # Action and Observation models
250
- └── server/
251
- ├── __init__.py # Server module exports
252
- ├── deployment_environment.py # Core environment logic
253
- ├── app.py # FastAPI application (HTTP + WebSocket endpoints)
254
- └── Dockerfile # Container image definition
255
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deployment/__pycache__/models.cpython-312.pyc DELETED
Binary file (2.46 kB)
 
deployment/baseline.py DELETED
@@ -1,146 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Baseline evaluation script for the Eco-LLM Inference Routing Environment.
4
-
5
- Policies:
6
- - random: Random valid strategy + model each step.
7
- - heuristic: SMALL by default; escalate to MEDIUM/LARGE only if needed;
8
- use cache when available; wait when carbon > 0.7.
9
- - llm: Uses Anthropic Claude via the API to decide strategy + model.
10
-
11
- Usage:
12
- python baseline.py --task easy
13
- python baseline.py --task medium --policy heuristic
14
- python baseline.py --task hard --policy llm --episodes 5
15
- """
16
-
17
- import argparse
18
- import random
19
- import statistics
20
- from typing import Tuple
21
-
22
- # ── Inline env import (no server needed for local eval) ──────────────────────
23
- import sys
24
- import os
25
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
-
27
- from server.deployment_environment import EcoLLMEnvironment
28
- from models import EcoLLMAction, EcoLLMObservation
29
-
30
- STRATEGIES = ["NONE", "USE_CACHE", "DO_CASCADE", "EARLY_EXIT", "WAIT", "CALL_KB"]
31
- MODELS = ["SMALL", "MEDIUM", "LARGE"]
32
-
33
-
34
- # ── Policies ─────────────────────────────────────────────────────────────────
35
-
36
- def random_policy(obs: EcoLLMObservation) -> EcoLLMAction:
37
- return EcoLLMAction(
38
- strategy=random.choice(STRATEGIES),
39
- model_choice=random.choice(MODELS),
40
- )
41
-
42
-
43
- def heuristic_policy(obs: EcoLLMObservation) -> EcoLLMAction:
44
- """Rule-based heuristic."""
45
- # Wait if carbon is very high and we haven't used all steps
46
- if obs.carbon_intensity > 0.7 and obs.step < obs.total_steps - 1:
47
- return EcoLLMAction(strategy="WAIT", model_choice="SMALL")
48
-
49
- # Use cache if query already answered
50
- if obs.query in obs.cache_contents:
51
- return EcoLLMAction(strategy="USE_CACHE", model_choice="SMALL")
52
-
53
- # For hard task, use CALL_KB for weather-like queries
54
- if obs.task == "hard" and "weather" in obs.query.lower():
55
- return EcoLLMAction(strategy="CALL_KB", model_choice="SMALL")
56
-
57
- # Default: try early-exit (SMALL first, saves energy)
58
- return EcoLLMAction(strategy="EARLY_EXIT", model_choice="MEDIUM")
59
-
60
-
61
- def llm_policy(obs: EcoLLMObservation) -> EcoLLMAction:
62
- """Use Claude claude-sonnet-4-20250514 to decide routing strategy."""
63
- try:
64
- import anthropic
65
- client = anthropic.Anthropic()
66
- prompt = (
67
- f"You are an LLM inference router. Choose the best strategy and model.\n"
68
- f"Query: {obs.query}\n"
69
- f"Carbon intensity (0=clean, 1=dirty): {obs.carbon_intensity:.2f}\n"
70
- f"Cache contains: {obs.cache_contents}\n"
71
- f"Step {obs.step+1}/{obs.total_steps}, Task: {obs.task}\n\n"
72
- f"Reply with EXACTLY two lines:\n"
73
- f"STRATEGY: <one of NONE|USE_CACHE|DO_CASCADE|EARLY_EXIT|WAIT|CALL_KB>\n"
74
- f"MODEL: <one of SMALL|MEDIUM|LARGE>"
75
- )
76
- response = client.messages.create(
77
- model="claude-sonnet-4-20250514",
78
- max_tokens=50,
79
- messages=[{"role": "user", "content": prompt}],
80
- )
81
- text = response.content[0].text.strip()
82
- lines = {l.split(":")[0].strip(): l.split(":")[1].strip()
83
- for l in text.splitlines() if ":" in l}
84
- strategy = lines.get("STRATEGY", "NONE").upper()
85
- model = lines.get("MODEL", "SMALL").upper()
86
- if strategy not in STRATEGIES:
87
- strategy = "NONE"
88
- if model not in MODELS:
89
- model = "SMALL"
90
- return EcoLLMAction(strategy=strategy, model_choice=model)
91
- except Exception as e:
92
- print(f" [LLM policy error: {e}] falling back to heuristic")
93
- return heuristic_policy(obs)
94
-
95
-
96
- # ── Episode runner ────────────────────────────────────────────────────────────
97
-
98
- def run_episode(task: str, policy_fn) -> Tuple[float, int]:
99
- env = EcoLLMEnvironment(task=task)
100
- obs = env.reset()
101
- total_reward = 0.0
102
- steps = 0
103
- while not obs.done:
104
- action = policy_fn(obs)
105
- obs = env.step(action)
106
- total_reward += obs.reward
107
- steps += 1
108
- max_possible = len(env._queries) * (1.0 + 0.5) # max per step ≈ 1.5 (cache bonus)
109
- normalized = total_reward / max_possible if max_possible > 0 else 0.0
110
- return normalized, steps
111
-
112
-
113
- # ── Main ──────────────────────────────────────────────────────────────────────
114
-
115
- def main():
116
- parser = argparse.ArgumentParser(description="Eco-LLM Routing Environment Baseline")
117
- parser.add_argument("--task", choices=["easy", "medium", "hard"], default="easy")
118
- parser.add_argument("--policy", choices=["random", "heuristic", "llm"], default="heuristic")
119
- parser.add_argument("--episodes", type=int, default=10)
120
- args = parser.parse_args()
121
-
122
- policy_map = {
123
- "random": random_policy,
124
- "heuristic": heuristic_policy,
125
- "llm": llm_policy,
126
- }
127
- policy_fn = policy_map[args.policy]
128
-
129
- print(f"\n{'='*55}")
130
- print(f" Eco-LLM Routing Baseline")
131
- print(f" Task: {args.task} | Policy: {args.policy} | Episodes: {args.episodes}")
132
- print(f"{'='*55}")
133
-
134
- scores = []
135
- for ep in range(1, args.episodes + 1):
136
- score, steps = run_episode(args.task, policy_fn)
137
- scores.append(score)
138
- print(f" Episode {ep:3d}: score={score:.3f} steps={steps}")
139
-
140
- print(f"\n Mean score : {statistics.mean(scores):.3f}")
141
- print(f" Std dev : {statistics.stdev(scores):.3f}" if len(scores) > 1 else "")
142
- print(f"{'='*55}\n")
143
-
144
-
145
- if __name__ == "__main__":
146
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deployment/pyproject.toml DELETED
@@ -1,45 +0,0 @@
1
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
-
7
- [build-system]
8
- requires = ["setuptools>=45", "wheel"]
9
- build-backend = "setuptools.build_meta"
10
-
11
- [project]
12
- name = "openenv-deployment"
13
- version = "0.1.0"
14
- description = "Deployment environment for OpenEnv"
15
- requires-python = ">=3.10"
16
- dependencies = [
17
- # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
- # install from github
19
- # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
- "openenv-core[core]>=0.2.2",
21
- # Environment-specific dependencies
22
- # Add all dependencies needed for your environment here
23
- # Examples:
24
- # "numpy>=1.19.0",
25
- # "torch>=2.0.0",
26
- # "gymnasium>=0.29.0",
27
- # "openspiel>=1.0.0",
28
- # "smolagents>=1.22.0,<2",
29
- ]
30
-
31
- [project.optional-dependencies]
32
- dev = [
33
- "pytest>=8.0.0",
34
- "pytest-cov>=4.0.0",
35
- ]
36
-
37
- [project.scripts]
38
- # Server entry point - enables running via: uv run --project . server
39
- # or: python -m deployment.server.app
40
- server = "deployment.server.app:main"
41
-
42
- [tool.setuptools]
43
- include-package-data = true
44
- packages = ["deployment", "deployment.server"]
45
- package-dir = { "deployment" = ".", "deployment.server" = "server" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deployment/server/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (300 Bytes)
 
deployment/server/__pycache__/deployment_environment.cpython-312.pyc DELETED
Binary file (11.2 kB)
 
deployment/server/requirements.txt DELETED
@@ -1,6 +0,0 @@
1
- openenv[core]>=0.2.0
2
- fastapi>=0.115.0
3
- uvicorn>=0.24.0
4
-
5
-
6
-
 
 
 
 
 
 
 
inference.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py - Eco-LLM Inference Routing Environment
3
+ ====================================================
4
+ Spec-compliant inference script for the OpenEnv hackathon.
5
+
6
+ Environment variables (required):
7
+ HF_TOKEN Your Hugging Face or API key
8
+ API_BASE_URL LLM endpoint (default: https://router.huggingface.co/v1)
9
+ MODEL_NAME Model ID (default: Qwen/Qwen2.5-72B-Instruct)
10
+ ECO_LLM_TASK Task to run (default: task_1)
11
+
12
+ Stdout format (mandatory):
13
+ [START] task=<task> env=eco_llm_inference_routing model=<model>
14
+ [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
15
+ [END] success=<true|false> steps=<n> rewards=<r1,r2,...,rn>
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import os
23
+ import textwrap
24
+ from typing import List, Optional
25
+
26
+ from openai import OpenAI
27
+
28
+
29
+ # ENVIRONMENT & CONFIG
30
+
31
+ HF_TOKEN = os.getenv("HF_TOKEN")
32
+
33
+ API_KEY = HF_TOKEN
34
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
35
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
36
+ TASK_ID = os.getenv("ECO_LLM_TASK", "task_1")
37
+ BENCHMARK = "eco_llm_inference_routing"
38
+
39
+ MAX_STEPS = 50
40
+ SUCCESS_SCORE_THRESHOLD = 0.5
41
+ TEMPERATURE = 0.2
42
+ MAX_TOKENS = 80
43
+
44
+ _MAX_REWARD_PER_STEP = 1.5
45
+ TASK_QUERY_COUNTS = {"task_1": 1, "task_2": 3, "task_3": 5}
46
+ _MAX_TOTAL_REWARD = TASK_QUERY_COUNTS.get(TASK_ID, 5) * _MAX_REWARD_PER_STEP
47
+
48
+ SYSTEM_PROMPT = textwrap.dedent(
49
+ """
50
+ You are an LLM inference router for the Eco-LLM environment.
51
+
52
+ At each step you receive:
53
+ - The current query
54
+ - Carbon intensity of the grid (0.0 = clean, 1.0 = very dirty)
55
+ - Cache contents (queries already answered correctly - re-use them for free)
56
+ - Whether a knowledge base (KB) is available for this query
57
+
58
+ Your goal is to maximise total reward:
59
+ reward = score - carbon_penalty - latency_penalty + bonuses
60
+ where:
61
+ score = 1.0 if model tier >= query minimum, else 0.0
62
+ carbon_penalty = carbon_intensity * energy_cost (capped at 0.8)
63
+ latency_penalty= 0.1 * latency_cost
64
+ cache_bonus = +0.5 for a cache hit
65
+ early_exit_bonus = +0.1 for EARLY_EXIT when correct
66
+
67
+ Model costs:
68
+ SMALL - energy 0.1, latency 1.0
69
+ MEDIUM - energy 0.3, latency 2.0
70
+ LARGE - energy 0.6, latency 5.0
71
+
72
+ Strategies:
73
+ NONE - direct inference with chosen model
74
+ USE_CACHE - serve from cache (only if query is in cache_contents)
75
+ DO_CASCADE - try models small->chosen, stop on exit_flag if correct
76
+ EARLY_EXIT - direct inference; bonus if correct and exits early
77
+ WAIT - skip step to wait for lower carbon (useful when carbon > 0.7)
78
+ CALL_KB - use knowledge base (only when kb_available=true)
79
+
80
+ Respond with ONLY valid JSON:
81
+ {
82
+ "strategy": "<NONE|USE_CACHE|DO_CASCADE|EARLY_EXIT|WAIT|CALL_KB>",
83
+ "model_choice": "<SMALL|MEDIUM|LARGE>",
84
+ "exit_flag": <true|false>
85
+ }
86
+ """
87
+ ).strip()
88
+
89
+
90
+ def build_user_prompt(
91
+ step: int,
92
+ query: str,
93
+ carbon_intensity: float,
94
+ cache_contents: List[str],
95
+ kb_available: bool,
96
+ last_reward: float,
97
+ history: List[str],
98
+ ) -> str:
99
+ """Build context-aware prompt for the LLM routing decision."""
100
+ cache_str = json.dumps(cache_contents) if cache_contents else "[]"
101
+ hist_block = "\n".join(history[-5:]) if history else "None"
102
+ in_cache = query in cache_contents
103
+ return textwrap.dedent(
104
+ f"""
105
+ Step: {step}
106
+ Query: {query!r}
107
+ In cache: {str(in_cache).lower()}
108
+ Cache: {cache_str}
109
+ Carbon intensity: {carbon_intensity:.2f}
110
+ KB available: {str(kb_available).lower()}
111
+ Last reward: {last_reward:.2f}
112
+
113
+ Recent history: {hist_block}
114
+
115
+ Choose the most energy-efficient routing action.
116
+ """
117
+ ).strip()
118
+
119
+
120
+ def log_start(task: str, env: str, model: str) -> None:
121
+ """Emit [START] line per spec."""
122
+ print(f"[START] task={task} env={env} model={model}", flush=True)
123
+
124
+
125
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
126
+ """Emit [STEP] line per spec."""
127
+ error_val = error if error else "null"
128
+ print(
129
+ f"[STEP] step={step} action={action} "
130
+ f"reward={reward:.2f} done={str(done).lower()} error={error_val}",
131
+ flush=True,
132
+ )
133
+
134
+
135
+ def log_end(success: bool, steps: int, rewards: List[float]) -> None:
136
+ """
137
+ Emit [END] line per OpenEnv spec.
138
+ Format: [END] success=<true|false> steps=<n> rewards=<r1,r2,...,rn>
139
+ """
140
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
141
+ print(
142
+ f"[END] success={str(success).lower()} steps={steps} "
143
+ f"rewards={rewards_str}",
144
+ flush=True,
145
+ )
146
+
147
+
148
+ def get_routing_action(
149
+ client: OpenAI,
150
+ step: int,
151
+ query: str,
152
+ carbon_intensity: float,
153
+ cache_contents: List[str],
154
+ kb_available: bool,
155
+ last_reward: float,
156
+ history: List[str],
157
+ ) -> dict:
158
+ """Call the LLM and parse a routing action. Falls back to a safe heuristic on error."""
159
+ user_prompt = build_user_prompt(
160
+ step,
161
+ query,
162
+ carbon_intensity,
163
+ cache_contents,
164
+ kb_available,
165
+ last_reward,
166
+ history,
167
+ )
168
+ try:
169
+ completion = client.chat.completions.create(
170
+ model=MODEL_NAME,
171
+ messages=[
172
+ {"role": "system", "content": SYSTEM_PROMPT},
173
+ {"role": "user", "content": user_prompt},
174
+ ],
175
+ temperature=TEMPERATURE,
176
+ max_tokens=MAX_TOKENS,
177
+ stream=False,
178
+ timeout=10.0,
179
+ )
180
+ raw = (completion.choices[0].message.content or "").strip()
181
+ raw = raw.replace("```json", "").replace("```", "").strip()
182
+ parsed = json.loads(raw)
183
+
184
+ strategy = parsed.get("strategy", "NONE").upper()
185
+ model_choice = parsed.get("model_choice", "SMALL").upper()
186
+ exit_flag = bool(parsed.get("exit_flag", False))
187
+
188
+ valid_strategies = {"NONE", "USE_CACHE", "DO_CASCADE", "EARLY_EXIT", "WAIT", "CALL_KB"}
189
+ valid_models = {"SMALL", "MEDIUM", "LARGE"}
190
+ if strategy not in valid_strategies:
191
+ strategy = "NONE"
192
+ if model_choice not in valid_models:
193
+ model_choice = "SMALL"
194
+
195
+ return {"strategy": strategy, "model_choice": model_choice, "exit_flag": exit_flag}
196
+
197
+ except (asyncio.TimeoutError, TimeoutError) as exc:
198
+ print(f"[DEBUG] LLM timeout at step {step}: {str(exc)}", flush=True)
199
+ if query in cache_contents:
200
+ return {"strategy": "USE_CACHE", "model_choice": "SMALL", "exit_flag": False}
201
+ if carbon_intensity > 0.7:
202
+ return {"strategy": "WAIT", "model_choice": "SMALL", "exit_flag": False}
203
+ return {"strategy": "NONE", "model_choice": "SMALL", "exit_flag": False}
204
+
205
+ except Exception as exc:
206
+ print(f"[DEBUG] LLM error at step {step}: {exc}", flush=True)
207
+ if query in cache_contents:
208
+ return {"strategy": "USE_CACHE", "model_choice": "SMALL", "exit_flag": False}
209
+ if kb_available:
210
+ return {"strategy": "CALL_KB", "model_choice": "SMALL", "exit_flag": False}
211
+ if carbon_intensity > 0.7:
212
+ return {"strategy": "WAIT", "model_choice": "SMALL", "exit_flag": False}
213
+ return {"strategy": "NONE", "model_choice": "SMALL", "exit_flag": True}
214
+
215
+
216
+ def action_to_str(action: dict) -> str:
217
+ """Convert an action dict to a compact string for the [STEP] line."""
218
+ strategy = action["strategy"]
219
+ model_choice = action["model_choice"]
220
+ exit_flag = str(action["exit_flag"]).lower()
221
+ return f"strategy={strategy},model={model_choice},exit={exit_flag}"
222
+
223
+
224
+ async def run_episode(client: OpenAI) -> None:
225
+ """Run one episode of the Eco-LLM inference routing environment."""
226
+ env = None
227
+ try:
228
+ from openenv.client import AsyncEnvClient # type: ignore
229
+
230
+ env = AsyncEnvClient(base_url=os.getenv("ENV_BASE_URL", "http://localhost:8000"))
231
+ except Exception:
232
+ from server.env import EcoLLMInferenceRoutingEnvironment # type: ignore
233
+
234
+ env = EcoLLMInferenceRoutingEnvironment()
235
+
236
+ rewards: List[float] = []
237
+ history: List[str] = []
238
+ steps_taken = 0
239
+ success = False
240
+
241
+ log_start(task=TASK_ID, env=BENCHMARK, model=MODEL_NAME)
242
+
243
+ try:
244
+ if asyncio.iscoroutinefunction(getattr(env, "reset", None)):
245
+ obs = await env.reset(task_id=TASK_ID)
246
+ else:
247
+ obs = env.reset(task_id=TASK_ID)
248
+
249
+ def _obs_field(observation: object, *keys: str) -> object:
250
+ for key in keys:
251
+ if isinstance(observation, dict):
252
+ if key in observation:
253
+ return observation[key]
254
+ elif hasattr(observation, key):
255
+ return getattr(observation, key)
256
+ return None
257
+
258
+ last_reward = 0.0
259
+ done = bool(_obs_field(obs, "done") or False)
260
+
261
+ for step in range(1, MAX_STEPS + 1):
262
+ if done:
263
+ break
264
+
265
+ query = _obs_field(obs, "query") or ""
266
+ carbon_intensity = float(_obs_field(obs, "carbon_intensity") or 0.5)
267
+ cache_contents = list(_obs_field(obs, "cache_contents") or [])
268
+ kb_available = bool(_obs_field(obs, "kb_available") or False)
269
+
270
+ action = get_routing_action(
271
+ client,
272
+ step,
273
+ str(query),
274
+ carbon_intensity,
275
+ cache_contents,
276
+ kb_available,
277
+ last_reward,
278
+ history,
279
+ )
280
+ action_str = action_to_str(action)
281
+
282
+ error_msg = None
283
+ try:
284
+ from server.models import ModelChoice, RLAction, Strategy # type: ignore
285
+
286
+ rl_action = RLAction(
287
+ strategy=Strategy(action["strategy"]),
288
+ model_choice=ModelChoice(action["model_choice"]),
289
+ exit_flag=action["exit_flag"],
290
+ )
291
+ if asyncio.iscoroutinefunction(getattr(env, "step", None)):
292
+ obs = await env.step(rl_action)
293
+ else:
294
+ obs = env.step(rl_action)
295
+ except Exception as exc:
296
+ error_msg = str(exc).replace("\n", " ")[:120]
297
+ print(f"[DEBUG] step error: {error_msg}", flush=True)
298
+ done = True
299
+ log_step(step=step, action=action_str, reward=0.0, done=True, error=error_msg)
300
+ break
301
+
302
+ reward = float(_obs_field(obs, "reward") or 0.0)
303
+ done = bool(_obs_field(obs, "done") or False)
304
+ last_reward = reward
305
+
306
+ rewards.append(reward)
307
+ steps_taken = step
308
+
309
+ log_step(step=step, action=action_str, reward=reward, done=done, error=error_msg)
310
+ history.append(
311
+ f"step={step} strategy={action['strategy']} "
312
+ f"model={action['model_choice']} reward={reward:+.2f}"
313
+ )
314
+
315
+ if done:
316
+ break
317
+
318
+ raw_total = sum(rewards)
319
+ max_total = _MAX_TOTAL_REWARD if _MAX_TOTAL_REWARD > 0 else 1.0
320
+ score = min(max(raw_total / max_total, 0.0), 1.0)
321
+ success = score >= SUCCESS_SCORE_THRESHOLD
322
+
323
+ finally:
324
+ try:
325
+ if asyncio.iscoroutinefunction(getattr(env, "close", None)):
326
+ await env.close()
327
+ elif hasattr(env, "close"):
328
+ env.close()
329
+ except Exception as exc:
330
+ print(f"[DEBUG] env.close() error: {exc}", flush=True)
331
+
332
+ log_end(success=success, steps=steps_taken, rewards=rewards)
333
+
334
+
335
+ def main() -> None:
336
+ """Main entry point."""
337
+ if not HF_TOKEN or not HF_TOKEN.strip():
338
+ raise ValueError(
339
+ "ERROR: HF_TOKEN environment variable is mandatory.\n"
340
+ "Please set: export HF_TOKEN='your_hugging_face_token'"
341
+ )
342
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
343
+ asyncio.run(run_episode(client))
344
+
345
+
346
+ if __name__ == "__main__":
347
+ main()
openenv.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: eco_llm_inference_routing
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+ description: "Carbon-aware RL environment for multi-objective LLM query routing"
8
+ version: "1.0.0"
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-eco-llm-inference-routing"
7
+ version = "0.1.0"
8
+ description = "Eco-LLM Inference Routing environment for OpenEnv"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "fastapi>=0.115.0",
12
+ "gradio>=4.0.0",
13
+ "numpy>=1.26.0",
14
+ "openenv-core[core]>=0.2.2",
15
+ "openai>=1.40.0",
16
+ "pydantic>=2.8.0",
17
+ "uvicorn>=0.30.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=8.0.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ server = "server.app:main"
27
+
28
+ [tool.setuptools]
29
+ include-package-data = true
30
+ packages = ["server"]
31
+ package-dir = { "server" = "server" }
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Server package for the Eco-LLM Inference Routing environment."""
server/__pycache__/env.cpython-312.pyc ADDED
Binary file (12 kB). View file
 
server/__pycache__/models.cpython-312.pyc ADDED
Binary file (4.84 kB). View file
 
server/__pycache__/tasks.cpython-312.pyc ADDED
Binary file (4.39 kB). View file
 
server/app.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app for the Eco-LLM Inference Routing environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from openenv.core.env_server.http_server import create_app
7
+ except Exception as exc: # pragma: no cover
8
+ raise ImportError(
9
+ "openenv-core is required. Install dependencies before running the server."
10
+ ) from exc
11
+
12
+ from server.env import EcoLLMInferenceRoutingEnvironment
13
+ from server.models import RLAction, RLObservation, Strategy, ModelChoice
14
+
15
+ app = create_app(
16
+ EcoLLMInferenceRoutingEnvironment,
17
+ RLAction,
18
+ RLObservation,
19
+ env_name="eco_llm_inference_routing",
20
+ max_concurrent_envs=4,
21
+ )
22
+
23
+ @app.get("/")
24
+ def root():
25
+ return {"status": "ok", "ui": "/ui"}
26
+
27
+
28
+ # ── Gradio demo — mounted at /ui, does NOT replace FastAPI ───────────────────
29
+ try:
30
+ import gradio as gr
31
+ from gradio.routes import mount_gradio_app
32
+
33
+ def reset_demo(task_id: str, state: dict) -> tuple:
34
+ demo_env = EcoLLMInferenceRoutingEnvironment()
35
+ obs = demo_env.reset(task_id=task_id)
36
+ updated_state = {
37
+ "env": demo_env,
38
+ "obs": obs,
39
+ "history": [],
40
+ "total_reward": 0.0,
41
+ }
42
+ log = f"🔄 Reset — Task: {task_id} ({demo_env.current_task.difficulty})\n"
43
+ log += f"📋 Query: {obs.query}\n"
44
+ log += f"🌍 Carbon intensity: {obs.carbon_intensity:.2f}\n"
45
+ log += f"💾 Cache: {obs.cache_contents or 'empty'}\n"
46
+ log += f"🔌 KB available: {obs.kb_available}"
47
+ return log, "", 0.0, updated_state
48
+
49
+ def step_demo(strategy: str, model: str, exit_flag: bool, state: dict) -> tuple:
50
+ demo_env = state.get("env")
51
+ if demo_env is None or state["obs"] is None:
52
+ return "⚠️ Click Reset first!", "", 0.0, state
53
+ obs = state["obs"]
54
+ if obs.done:
55
+ return "✅ Episode done — click Reset to start again.", "", state["total_reward"], state
56
+
57
+ action = RLAction(
58
+ strategy=Strategy(strategy),
59
+ model_choice=ModelChoice(model),
60
+ exit_flag=exit_flag,
61
+ )
62
+ new_obs = demo_env.step(action)
63
+ history = list(state["history"])
64
+ rd = new_obs.reward_details
65
+ total_reward = state["total_reward"] + rd.total_reward
66
+
67
+ history_line = (
68
+ f"Step {len(history)+1}: "
69
+ f"{strategy}+{model} → reward={rd.total_reward:+.3f} "
70
+ f"({'✅' if rd.correct else '❌'})"
71
+ )
72
+ history.append(history_line)
73
+
74
+ log = "\n".join(history) + "\n\n"
75
+ if not new_obs.done:
76
+ log += f"📋 Next query: {new_obs.query}\n"
77
+ log += f"🌍 Carbon intensity: {new_obs.carbon_intensity:.2f}\n"
78
+ log += f"💾 Cache: {new_obs.cache_contents or 'empty'}\n"
79
+ log += f"🔌 KB available: {new_obs.kb_available}"
80
+ else:
81
+ log += "✅ Episode complete!"
82
+
83
+ breakdown = (
84
+ f"score={rd.score:.1f} "
85
+ f"energy_cost={rd.energy_cost:.2f} "
86
+ f"carbon_penalty={rd.carbon_penalty:.3f} "
87
+ f"latency_penalty={rd.latency_penalty:.3f} "
88
+ f"bonuses={rd.bonuses:.2f} "
89
+ f"→ total={rd.total_reward:+.3f}"
90
+ )
91
+ updated_state = {
92
+ "env": demo_env,
93
+ "obs": new_obs,
94
+ "history": history,
95
+ "total_reward": total_reward,
96
+ }
97
+ return log, breakdown, round(total_reward, 3), updated_state
98
+
99
+ with gr.Blocks(title="Eco-LLM Routing Demo") as demo:
100
+ demo_state = gr.State({"obs": None, "history": [], "total_reward": 0.0})
101
+ gr.Markdown("""
102
+ # 🌱 Eco-LLM Inference Routing Environment
103
+ **Carbon-aware LLM query routing** — balance accuracy, energy, latency & carbon footprint.
104
+ Choose a routing strategy and model for each query to maximise cumulative reward.
105
+ """)
106
+
107
+ with gr.Row():
108
+ task_selector = gr.Dropdown(
109
+ choices=["task_1", "task_2", "task_3"],
110
+ value="task_1",
111
+ label="Task (easy → hard)",
112
+ )
113
+ reset_btn = gr.Button("🔄 Reset Episode", variant="primary")
114
+
115
+ obs_box = gr.Textbox(label="Environment State", lines=6, interactive=False)
116
+
117
+ gr.Markdown("### Take an Action")
118
+ with gr.Row():
119
+ strategy_dd = gr.Dropdown(
120
+ choices=["NONE", "USE_CACHE", "DO_CASCADE", "EARLY_EXIT", "WAIT", "CALL_KB"],
121
+ value="NONE",
122
+ label="Strategy",
123
+ )
124
+ model_dd = gr.Dropdown(
125
+ choices=["SMALL", "MEDIUM", "LARGE"],
126
+ value="SMALL",
127
+ label="Model",
128
+ )
129
+ exit_cb = gr.Checkbox(label="exit_flag (cascade early stop)", value=False)
130
+ step_btn = gr.Button("▶️ Step", variant="secondary")
131
+
132
+ reward_breakdown = gr.Textbox(label="Reward Breakdown", interactive=False)
133
+ total_reward_num = gr.Number(label="Cumulative Reward", interactive=False)
134
+
135
+ gr.Markdown("""
136
+ ### Reward Formula
137
+ `reward = score − (carbon_intensity × energy_cost) − (0.1 × latency) + bonuses`
138
+
139
+ | Model | Energy | Latency | | Strategy | Effect |
140
+ |---|---|---|---|---|---|
141
+ | SMALL | 0.1 | 1.0s | | USE_CACHE | +0.5 bonus if cached |
142
+ | MEDIUM | 0.3 | 2.0s | | CALL_KB | cheap if kb_available |
143
+ | LARGE | 0.6 | 5.0s | | WAIT | drops carbon next step |
144
+ """)
145
+
146
+ reset_btn.click(
147
+ reset_demo,
148
+ inputs=[task_selector, demo_state],
149
+ outputs=[obs_box, reward_breakdown, total_reward_num, demo_state],
150
+ )
151
+ step_btn.click(
152
+ step_demo,
153
+ inputs=[strategy_dd, model_dd, exit_cb, demo_state],
154
+ outputs=[obs_box, reward_breakdown, total_reward_num, demo_state],
155
+ )
156
+
157
+ app = mount_gradio_app(app, demo, path="/ui")
158
+
159
+ except ImportError:
160
+ # Gradio not installed — API still works fine
161
+ pass
162
+
163
+
164
+ def main(host: str = "0.0.0.0", port: int = 7860) -> None:
165
+ """Run the development server."""
166
+ import uvicorn
167
+ uvicorn.run(app, host=host, port=port)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ import argparse
172
+ parser = argparse.ArgumentParser()
173
+ parser.add_argument("--port", type=int, default=7860)
174
+ args = parser.parse_args()
175
+ main(port=args.port)
server/env.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment logic for Eco-LLM inference routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+ from uuid import uuid4
7
+
8
+ from openenv.core.env_server.interfaces import Environment
9
+ from openenv.core.env_server.types import EnvironmentMetadata
10
+
11
+ from server.models import ModelChoice, RLAction, RLObservation, RLReward, RLState, Strategy
12
+ from server.tasks import (
13
+ CARBON_SCHEDULE,
14
+ ENERGY_COSTS,
15
+ KB_ENERGY_COST,
16
+ KB_LATENCY_COST,
17
+ LATENCY_COSTS,
18
+ LATENCY_MU,
19
+ MODEL_ORDER,
20
+ MODEL_RANK,
21
+ MAX_STEPS_PER_EPISODE,
22
+ MAX_CARBON_PENALTY,
23
+ QuerySpec,
24
+ TaskSpec,
25
+ get_task,
26
+ )
27
+
28
+
29
+ class EcoLLMInferenceRoutingEnvironment(Environment[RLAction, RLObservation, RLState]):
30
+ """Deterministic hierarchical RL environment for LLM routing."""
31
+
32
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
33
+
34
+ def __init__(self) -> None:
35
+ super().__init__()
36
+ self.current_task: TaskSpec = get_task("task_1")
37
+ self._state = RLState(
38
+ episode_id=str(uuid4()),
39
+ step_count=0,
40
+ task_id=self.current_task.task_id,
41
+ step_index=0,
42
+ query_index=0,
43
+ total_energy=0.0,
44
+ total_latency=0.0,
45
+ cache_contents=[],
46
+ carbon_history_index=0,
47
+ )
48
+
49
+ def reset(
50
+ self,
51
+ seed: Optional[int] = None,
52
+ episode_id: Optional[str] = None,
53
+ task_id: str = "task_1",
54
+ **kwargs: object,
55
+ ) -> RLObservation:
56
+ del seed, kwargs
57
+ self.current_task = get_task(task_id)
58
+ self._state = RLState(
59
+ episode_id=episode_id or str(uuid4()),
60
+ step_count=0,
61
+ task_id=self.current_task.task_id,
62
+ step_index=0,
63
+ query_index=0,
64
+ total_energy=0.0,
65
+ total_latency=0.0,
66
+ cache_contents=[],
67
+ carbon_history_index=0,
68
+ )
69
+ return self._build_observation(
70
+ reward=self._empty_reward(["reset"]),
71
+ done=False,
72
+ )
73
+
74
+ def step(
75
+ self,
76
+ action: RLAction,
77
+ timeout_s: Optional[float] = None,
78
+ **kwargs: object,
79
+ ) -> RLObservation:
80
+ del timeout_s, kwargs
81
+
82
+ if self._episode_complete:
83
+ raise RuntimeError(
84
+ f"Episode {self._state.episode_id!r} is already complete. "
85
+ "Call reset() to start a new episode."
86
+ )
87
+ if self._state.step_count >= MAX_STEPS_PER_EPISODE:
88
+ return self._build_observation(
89
+ reward=self._empty_reward(["step_limit_exceeded"]),
90
+ done=True,
91
+ )
92
+
93
+ self._state.step_count += 1
94
+ trace = [f"strategy={action.strategy.value}", f"model={action.model_choice.value}"]
95
+ query = self.current_query
96
+ current_carbon = self.current_carbon_intensity
97
+
98
+ if action.strategy == Strategy.WAIT:
99
+ self._advance_carbon()
100
+ self._state.step_index += 1
101
+ reward = RLReward(
102
+ score=0.0,
103
+ energy_cost=0.0,
104
+ latency_cost=0.0,
105
+ carbon_penalty=0.0,
106
+ latency_penalty=0.0,
107
+ bonuses=0.0,
108
+ total_reward=0.0,
109
+ correct=False,
110
+ strategy_trace=trace + ["wait"],
111
+ )
112
+ return self._build_observation(reward=reward, done=False)
113
+
114
+ correct = False
115
+ energy_cost = 0.0
116
+ latency_cost = 0.0
117
+ bonuses = 0.0
118
+
119
+ if action.strategy == Strategy.USE_CACHE and query.text in self.cache_contents:
120
+ correct = True
121
+ bonuses += 0.5
122
+ trace.append("cache_hit")
123
+ elif action.strategy == Strategy.CALL_KB:
124
+ energy_cost += KB_ENERGY_COST
125
+ latency_cost += KB_LATENCY_COST
126
+ correct = query.kb_available
127
+ trace.append("kb_lookup")
128
+ elif action.strategy == Strategy.DO_CASCADE:
129
+ cascade_models = self._cascade_models(action.model_choice)
130
+ for cascade_model in cascade_models:
131
+ energy_cost += ENERGY_COSTS[cascade_model]
132
+ latency_cost += LATENCY_COSTS[cascade_model]
133
+ trace.append(f"cascade:{cascade_model.value}")
134
+ model_correct = self._model_answers_correctly(query, cascade_model)
135
+ if model_correct:
136
+ correct = True
137
+ if action.exit_flag:
138
+ trace.append("cascade_exit")
139
+ break
140
+ else:
141
+ chosen_model = action.model_choice
142
+ energy_cost += ENERGY_COSTS[chosen_model]
143
+ latency_cost += LATENCY_COSTS[chosen_model]
144
+ correct = self._model_answers_correctly(query, chosen_model)
145
+ trace.append(f"infer:{chosen_model.value}")
146
+ if action.strategy == Strategy.EARLY_EXIT and correct:
147
+ trace.append("early_exit_success")
148
+
149
+ if action.strategy == Strategy.EARLY_EXIT and correct:
150
+ bonuses += 0.1
151
+
152
+ if action.model_choice == ModelChoice.LARGE:
153
+ bonuses += self.current_task.large_model_penalty
154
+ if self.current_task.large_model_penalty:
155
+ trace.append("task_large_penalty")
156
+
157
+ score = 1.0 if correct else 0.0
158
+ carbon_penalty = min(current_carbon * energy_cost, MAX_CARBON_PENALTY)
159
+ latency_penalty = LATENCY_MU * latency_cost
160
+ total_reward = score - carbon_penalty - latency_penalty + bonuses
161
+
162
+ self._state.total_energy += energy_cost
163
+ self._state.total_latency += latency_cost
164
+ self._state.step_index += 1
165
+
166
+ if correct and query.text not in self.cache_contents:
167
+ self._state.cache_contents.append(query.text)
168
+
169
+ if action.strategy != Strategy.WAIT:
170
+ self._advance_query()
171
+ self._advance_carbon()
172
+
173
+ done = self._episode_complete
174
+ reward = RLReward(
175
+ score=score,
176
+ energy_cost=round(energy_cost, 4),
177
+ latency_cost=round(latency_cost, 4),
178
+ carbon_penalty=round(carbon_penalty, 4),
179
+ latency_penalty=round(latency_penalty, 4),
180
+ bonuses=round(bonuses, 4),
181
+ total_reward=round(total_reward, 4),
182
+ correct=correct,
183
+ strategy_trace=trace,
184
+ )
185
+ return self._build_observation(reward=reward, done=done)
186
+
187
+ @property
188
+ def state(self) -> RLState:
189
+ return self._state
190
+
191
+ @property
192
+ def cache_contents(self) -> list[str]:
193
+ return list(self._state.cache_contents)
194
+
195
+ @property
196
+ def current_query(self) -> QuerySpec:
197
+ idx = self._state.query_index
198
+ queries = self.current_task.queries
199
+ # Clamp to last query only for building the terminal observation
200
+ return queries[min(idx, len(queries) - 1)]
201
+
202
+ @property
203
+ def current_carbon_intensity(self) -> float:
204
+ return CARBON_SCHEDULE[self._state.carbon_history_index % len(CARBON_SCHEDULE)]
205
+
206
+ @property
207
+ def _episode_complete(self) -> bool:
208
+ return self._state.query_index >= len(self.current_task.queries)
209
+
210
+ def get_metadata(self) -> EnvironmentMetadata:
211
+ return EnvironmentMetadata(
212
+ name="Eco-LLM Inference Routing Environment",
213
+ description=(
214
+ "Hierarchical RL environment for carbon-aware LLM query routing "
215
+ "across accuracy, latency, energy, and caching objectives."
216
+ ),
217
+ version="1.0.0",
218
+ )
219
+
220
+ def _build_observation(self, reward: RLReward, done: bool) -> RLObservation:
221
+ query = self.current_task.queries[-1] if done else self.current_query
222
+ return RLObservation(
223
+ query=query.text,
224
+ cache_contents=self.cache_contents,
225
+ carbon_intensity=self.current_carbon_intensity,
226
+ done=done,
227
+ reward=reward.total_reward,
228
+ reward_details=reward,
229
+ kb_available=query.kb_available,
230
+ metadata={
231
+ "task_id": self.current_task.task_id,
232
+ "difficulty": self.current_task.difficulty,
233
+ "state": self._state.public_payload(),
234
+ },
235
+ )
236
+
237
+ def _empty_reward(self, trace: list[str]) -> RLReward:
238
+ return RLReward(
239
+ score=0.0,
240
+ energy_cost=0.0,
241
+ latency_cost=0.0,
242
+ carbon_penalty=0.0,
243
+ latency_penalty=0.0,
244
+ bonuses=0.0,
245
+ total_reward=0.0,
246
+ correct=False,
247
+ strategy_trace=trace,
248
+ )
249
+
250
+ def _advance_query(self) -> None:
251
+ if self._state.query_index < len(self.current_task.queries):
252
+ self._state.query_index += 1
253
+
254
+ def _advance_carbon(self) -> None:
255
+ self._state.carbon_history_index = (self._state.carbon_history_index + 1) % len(CARBON_SCHEDULE)
256
+
257
+ def _cascade_models(self, upper_bound: ModelChoice) -> list[ModelChoice]:
258
+ max_index = MODEL_RANK[upper_bound]
259
+ return MODEL_ORDER[: max_index + 1]
260
+
261
+ def _model_answers_correctly(self, query: QuerySpec, model: ModelChoice) -> bool:
262
+ return MODEL_RANK[model] >= MODEL_RANK[query.minimum_model]
server/models.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for the Eco-LLM Inference Routing environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from openenv.core.env_server.types import Action, Observation, State
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class Strategy(str, Enum):
13
+ NONE = "NONE"
14
+ USE_CACHE = "USE_CACHE"
15
+ DO_CASCADE = "DO_CASCADE"
16
+ EARLY_EXIT = "EARLY_EXIT"
17
+ WAIT = "WAIT"
18
+ CALL_KB = "CALL_KB"
19
+
20
+
21
+ class ModelChoice(str, Enum):
22
+ SMALL = "SMALL"
23
+ MEDIUM = "MEDIUM"
24
+ LARGE = "LARGE"
25
+
26
+
27
+ class RLAction(Action):
28
+ """Action taken by the routing agent at each step."""
29
+
30
+ strategy: Strategy = Field(..., description="Routing strategy to apply")
31
+ model_choice: ModelChoice = Field(..., description="Primary model for inference")
32
+ exit_flag: bool = Field(..., description="Whether to terminate a cascade early")
33
+
34
+
35
+ class RLReward(BaseModel):
36
+ """Structured reward breakdown for deterministic grading."""
37
+
38
+ score: float = Field(..., description="1.0 if correct else 0.0")
39
+ energy_cost: float = Field(..., description="Energy consumed this step")
40
+ latency_cost: float = Field(..., description="Latency incurred this step")
41
+ carbon_penalty: float = Field(..., description="Carbon-weighted energy penalty")
42
+ latency_penalty: float = Field(..., description="Latency penalty with mu=0.01")
43
+ bonuses: float = Field(..., description="Sum of bonuses or task-specific adjustments")
44
+ total_reward: float = Field(..., description="Final reward for the step")
45
+ correct: bool = Field(..., description="Whether the selected answer was correct")
46
+ strategy_trace: list[str] = Field(
47
+ default_factory=list,
48
+ description="Ordered record of sub-actions executed during the step",
49
+ )
50
+
51
+
52
+ class RLObservation(Observation):
53
+ """Observation returned after reset or step."""
54
+
55
+ query: str = Field(..., description="Current query to answer")
56
+ cache_contents: list[str] = Field(
57
+ default_factory=list,
58
+ description="Queries answered correctly and available in cache",
59
+ )
60
+ carbon_intensity: float = Field(..., ge=0.0, le=1.0, description="Current carbon intensity")
61
+ reward_details: RLReward = Field(..., description="Structured reward payload")
62
+ kb_available: bool = Field(default=False, description="Whether KB is available for current query")
63
+
64
+
65
+ class RLState(State):
66
+ """Internal environment state."""
67
+
68
+ task_id: str = Field(..., description="Active task identifier")
69
+ step_index: int = Field(..., ge=0, description="Index within the current task trajectory")
70
+ query_index: int = Field(..., ge=0, description="Index of the active query")
71
+ total_energy: float = Field(default=0.0, ge=0.0, description="Cumulative energy cost")
72
+ total_latency: float = Field(default=0.0, ge=0.0, description="Cumulative latency cost")
73
+ cache_contents: list[str] = Field(default_factory=list, description="Current cache contents")
74
+ carbon_history_index: int = Field(default=0, ge=0, description="Current carbon history pointer")
75
+
76
+ def public_payload(self) -> dict[str, Any]:
77
+ return self.model_dump()
server/tasks.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic task definitions for the Eco-LLM routing environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from server.models import ModelChoice
8
+
9
+
10
+ ENERGY_COSTS = {
11
+ ModelChoice.SMALL: 0.1,
12
+ ModelChoice.MEDIUM: 0.3,
13
+ ModelChoice.LARGE: 0.6,
14
+ }
15
+
16
+ LATENCY_COSTS = {
17
+ ModelChoice.SMALL: 1.0,
18
+ ModelChoice.MEDIUM: 2.0,
19
+ ModelChoice.LARGE: 5.0,
20
+ }
21
+
22
+ MODEL_ORDER = [ModelChoice.SMALL, ModelChoice.MEDIUM, ModelChoice.LARGE]
23
+ MODEL_RANK = {model: index for index, model in enumerate(MODEL_ORDER)}
24
+ CARBON_SCHEDULE = [0.8, 0.6, 0.4, 0.3, 0.5]
25
+ LATENCY_MU = 0.1
26
+ MAX_CARBON_PENALTY = 0.8
27
+ KB_ENERGY_COST = 0.05
28
+ KB_LATENCY_COST = 1.5
29
+ MAX_STEPS_PER_EPISODE = 50
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class QuerySpec:
34
+ text: str
35
+ correct_answer: str
36
+ minimum_model: ModelChoice
37
+ kb_available: bool = False
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class TaskSpec:
42
+ task_id: str
43
+ difficulty: str
44
+ queries: tuple[QuerySpec, ...]
45
+ large_model_penalty: float = 0.0
46
+ description: str = ""
47
+
48
+
49
+ TASKS: dict[str, TaskSpec] = {
50
+ "task_1": TaskSpec(
51
+ task_id="task_1",
52
+ difficulty="easy",
53
+ queries=(
54
+ QuerySpec(
55
+ text="What is the most efficient insulation material for a passive house retrofit?",
56
+ correct_answer="Vacuum insulated panels",
57
+ minimum_model=ModelChoice.MEDIUM,
58
+ ),
59
+ ),
60
+ description="Single-query routing with accuracy-only emphasis.",
61
+ ),
62
+ "task_2": TaskSpec(
63
+ task_id="task_2",
64
+ difficulty="medium",
65
+ queries=(
66
+ QuerySpec(
67
+ text="Summarize the trade-off between quantization and factual accuracy in a customer support bot.",
68
+ correct_answer="Aggressive quantization lowers energy use but can reduce factual reliability.",
69
+ minimum_model=ModelChoice.SMALL,
70
+ ),
71
+ QuerySpec(
72
+ text="Explain when speculative decoding improves throughput on medium-sized models.",
73
+ correct_answer="It helps when draft tokens are likely to be accepted by the target model.",
74
+ minimum_model=ModelChoice.MEDIUM,
75
+ ),
76
+ QuerySpec(
77
+ text="Design a safety fallback for multilingual medical triage.",
78
+ correct_answer="Use high-capability review with escalation and verified retrieval before final output.",
79
+ minimum_model=ModelChoice.LARGE,
80
+ ),
81
+ ),
82
+ large_model_penalty=-0.2,
83
+ description="Three-query episode with an extra penalty for using the LARGE model.",
84
+ ),
85
+ "task_3": TaskSpec(
86
+ task_id="task_3",
87
+ difficulty="hard",
88
+ queries=(
89
+ QuerySpec(
90
+ text="Classify whether this FAQ request can be answered from the billing knowledge base.",
91
+ correct_answer="Yes, use the billing KB article.",
92
+ minimum_model=ModelChoice.SMALL,
93
+ kb_available=True,
94
+ ),
95
+ QuerySpec(
96
+ text="Route a long-form reasoning question about carbon-aware serving policy.",
97
+ correct_answer="Use cascade with escalation to a larger model if smaller ones fail.",
98
+ minimum_model=ModelChoice.MEDIUM,
99
+ ),
100
+ QuerySpec(
101
+ text="Classify whether this FAQ request can be answered from the billing knowledge base.",
102
+ correct_answer="Yes, use the billing KB article.",
103
+ minimum_model=ModelChoice.SMALL,
104
+ kb_available=True,
105
+ ),
106
+ QuerySpec(
107
+ text="Generate a compliant answer for a finance-regulated enterprise user.",
108
+ correct_answer="Use the large model with a retrieval-backed compliance layer.",
109
+ minimum_model=ModelChoice.LARGE,
110
+ ),
111
+ QuerySpec(
112
+ text="Route a long-form reasoning question about carbon-aware serving policy.",
113
+ correct_answer="Use cascade with escalation to a larger model if smaller ones fail.",
114
+ minimum_model=ModelChoice.MEDIUM,
115
+ ),
116
+ ),
117
+ description="Stateful five-query episode with repeats, caching, cascading, early exit, and waiting.",
118
+ ),
119
+ }
120
+
121
+
122
+ def get_task(task_id: str) -> TaskSpec:
123
+ """Return a task by id, defaulting to the easy task for unknown ids."""
124
+ return TASKS.get(task_id, TASKS["task_1"])
tests/test_agents.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for baseline agents."""
2
+
3
+ from baseline import HeuristicRoutingAgent, RandomRoutingAgent
4
+ from server.env import EcoLLMInferenceRoutingEnvironment
5
+ from server.models import Strategy
6
+
7
+
8
+ def test_random_agent_creates_valid_actions() -> None:
9
+ """Random agent produces valid RLAction values."""
10
+ agent = RandomRoutingAgent()
11
+ env = EcoLLMInferenceRoutingEnvironment()
12
+ obs = env.reset(task_id="task_1")
13
+
14
+ action = agent.get_action(obs)
15
+ assert action.strategy is not None
16
+ assert action.model_choice is not None
17
+ assert isinstance(action.exit_flag, bool)
18
+
19
+
20
+ def test_heuristic_agent_prefers_cache() -> None:
21
+ """Heuristic agent should prioritize cache hits."""
22
+ agent = HeuristicRoutingAgent()
23
+ env = EcoLLMInferenceRoutingEnvironment()
24
+ obs = env.reset(task_id="task_1")
25
+
26
+ obs.cache_contents.append(obs.query)
27
+ action = agent.get_action(obs)
28
+
29
+ assert action.strategy == Strategy.USE_CACHE
30
+
31
+
32
+ def test_random_agent_deterministic_with_seed() -> None:
33
+ """Random agent should be deterministic with the same seed."""
34
+ agent1 = RandomRoutingAgent(seed=42)
35
+ agent2 = RandomRoutingAgent(seed=42)
36
+
37
+ env = EcoLLMInferenceRoutingEnvironment()
38
+ obs = env.reset(task_id="task_1")
39
+
40
+ action1 = agent1.get_action(obs)
41
+ action2 = agent2.get_action(obs)
42
+
43
+ assert action1.strategy == action2.strategy
44
+ assert action1.model_choice == action2.model_choice
45
+ assert action1.exit_flag == action2.exit_flag
deployment/uv.lock → uv.lock RENAMED
@@ -475,182 +475,64 @@ wheels = [
475
  { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
476
  ]
477
 
478
- [[package]]
479
- name = "coverage"
480
- version = "7.13.5"
481
- source = { registry = "https://pypi.org/simple" }
482
- sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
483
- wheels = [
484
- { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" },
485
- { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" },
486
- { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" },
487
- { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" },
488
- { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" },
489
- { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" },
490
- { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" },
491
- { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" },
492
- { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" },
493
- { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" },
494
- { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" },
495
- { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" },
496
- { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" },
497
- { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" },
498
- { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
499
- { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
500
- { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
501
- { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
502
- { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
503
- { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
504
- { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
505
- { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
506
- { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
507
- { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
508
- { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
509
- { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
510
- { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
511
- { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
512
- { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
513
- { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
514
- { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
515
- { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
516
- { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
517
- { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
518
- { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
519
- { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
520
- { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
521
- { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
522
- { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
523
- { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
524
- { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
525
- { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
526
- { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
527
- { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
528
- { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
529
- { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
530
- { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
531
- { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
532
- { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
533
- { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
534
- { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
535
- { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
536
- { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
537
- { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
538
- { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
539
- { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
540
- { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
541
- { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
542
- { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
543
- { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
544
- { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
545
- { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
546
- { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
547
- { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
548
- { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
549
- { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
550
- { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
551
- { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
552
- { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
553
- { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
554
- { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
555
- { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
556
- { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
557
- { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
558
- { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
559
- { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
560
- { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
561
- { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
562
- { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
563
- { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
564
- { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
565
- { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
566
- { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
567
- { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
568
- { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
569
- { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
570
- { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
571
- { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
572
- { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
573
- { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
574
- { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
575
- { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
576
- { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
577
- { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
578
- { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
579
- { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
580
- { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
581
- { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
582
- { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
583
- { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
584
- { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
585
- { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
586
- { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
587
- { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
588
- { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
589
- ]
590
-
591
- [package.optional-dependencies]
592
- toml = [
593
- { name = "tomli", marker = "python_full_version <= '3.11'" },
594
- ]
595
-
596
  [[package]]
597
  name = "cryptography"
598
- version = "46.0.6"
599
  source = { registry = "https://pypi.org/simple" }
600
  dependencies = [
601
  { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
602
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
603
  ]
604
- sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
605
- wheels = [
606
- { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
607
- { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
608
- { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
609
- { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
610
- { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
611
- { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
612
- { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
613
- { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
614
- { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
615
- { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
616
- { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
617
- { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
618
- { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
619
- { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
620
- { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
621
- { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
622
- { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
623
- { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
624
- { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
625
- { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
626
- { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
627
- { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
628
- { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
629
- { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
630
- { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
631
- { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
632
- { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
633
- { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
634
- { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
635
- { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
636
- { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
637
- { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
638
- { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
639
- { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
640
- { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
641
- { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
642
- { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
643
- { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
644
- { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
645
- { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
646
- { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
647
- { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
648
- { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
649
- { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
650
- { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
651
- { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
652
- { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
653
- { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
654
  ]
655
 
656
  [[package]]
@@ -967,7 +849,7 @@ wheels = [
967
 
968
  [[package]]
969
  name = "huggingface-hub"
970
- version = "1.9.0"
971
  source = { registry = "https://pypi.org/simple" }
972
  dependencies = [
973
  { name = "filelock" },
@@ -980,9 +862,9 @@ dependencies = [
980
  { name = "typer" },
981
  { name = "typing-extensions" },
982
  ]
983
- sdist = { url = "https://files.pythonhosted.org/packages/88/bb/62c7aa86f63a05e2f9b96642fdef9b94526a23979820b09f5455deff4983/huggingface_hub-1.9.0.tar.gz", hash = "sha256:0ea5be7a56135c91797cae6ad726e38eaeb6eb4b77cefff5c9d38ba0ecf874f7", size = 750326, upload-time = "2026-04-03T08:35:55.888Z" }
984
  wheels = [
985
- { url = "https://files.pythonhosted.org/packages/73/37/0d15d16150e1829f3e90962c99f28257f6de9e526a680b4c6f5acdb54fd2/huggingface_hub-1.9.0-py3-none-any.whl", hash = "sha256:2999328c058d39fd19ab748dd09bd4da2fbaa4f4c1ddea823eab103051e14a1f", size = 637355, upload-time = "2026-04-03T08:35:53.897Z" },
986
  ]
987
 
988
  [[package]]
@@ -1599,24 +1481,35 @@ core = [
1599
  ]
1600
 
1601
  [[package]]
1602
- name = "openenv-deployment"
1603
  version = "0.1.0"
1604
  source = { editable = "." }
1605
  dependencies = [
 
 
 
 
 
1606
  { name = "openenv-core", extra = ["core"] },
 
 
1607
  ]
1608
 
1609
  [package.optional-dependencies]
1610
  dev = [
1611
  { name = "pytest" },
1612
- { name = "pytest-cov" },
1613
  ]
1614
 
1615
  [package.metadata]
1616
  requires-dist = [
 
 
 
 
1617
  { name = "openenv-core", extras = ["core"], specifier = ">=0.2.2" },
 
1618
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
1619
- { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
1620
  ]
1621
  provides-extras = ["dev"]
1622
 
@@ -2215,7 +2108,7 @@ wheels = [
2215
 
2216
  [[package]]
2217
  name = "pytest"
2218
- version = "9.0.2"
2219
  source = { registry = "https://pypi.org/simple" }
2220
  dependencies = [
2221
  { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2226,23 +2119,9 @@ dependencies = [
2226
  { name = "pygments" },
2227
  { name = "tomli", marker = "python_full_version < '3.11'" },
2228
  ]
2229
- sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
2230
- wheels = [
2231
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
2232
- ]
2233
-
2234
- [[package]]
2235
- name = "pytest-cov"
2236
- version = "7.1.0"
2237
- source = { registry = "https://pypi.org/simple" }
2238
- dependencies = [
2239
- { name = "coverage", extra = ["toml"] },
2240
- { name = "pluggy" },
2241
- { name = "pytest" },
2242
- ]
2243
- sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
2244
  wheels = [
2245
- { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
2246
  ]
2247
 
2248
  [[package]]
@@ -2268,11 +2147,11 @@ wheels = [
2268
 
2269
  [[package]]
2270
  name = "python-multipart"
2271
- version = "0.0.22"
2272
  source = { registry = "https://pypi.org/simple" }
2273
- sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
2274
  wheels = [
2275
- { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
2276
  ]
2277
 
2278
  [[package]]
@@ -2774,11 +2653,11 @@ wheels = [
2774
 
2775
  [[package]]
2776
  name = "uncalled-for"
2777
- version = "0.2.0"
2778
  source = { registry = "https://pypi.org/simple" }
2779
- sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" }
2780
  wheels = [
2781
- { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" },
2782
  ]
2783
 
2784
  [[package]]
@@ -2792,16 +2671,16 @@ wheels = [
2792
 
2793
  [[package]]
2794
  name = "uvicorn"
2795
- version = "0.43.0"
2796
  source = { registry = "https://pypi.org/simple" }
2797
  dependencies = [
2798
  { name = "click" },
2799
  { name = "h11" },
2800
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
2801
  ]
2802
- sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" }
2803
  wheels = [
2804
- { url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" },
2805
  ]
2806
 
2807
  [[package]]
 
475
  { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
476
  ]
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  [[package]]
479
  name = "cryptography"
480
+ version = "46.0.7"
481
  source = { registry = "https://pypi.org/simple" }
482
  dependencies = [
483
  { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
484
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
485
  ]
486
+ sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
487
+ wheels = [
488
+ { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
489
+ { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
490
+ { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
491
+ { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
492
+ { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
493
+ { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
494
+ { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
495
+ { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
496
+ { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
497
+ { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
498
+ { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
499
+ { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
500
+ { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
501
+ { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
502
+ { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
503
+ { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
504
+ { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
505
+ { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
506
+ { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
507
+ { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
508
+ { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
509
+ { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
510
+ { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
511
+ { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
512
+ { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
513
+ { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
514
+ { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
515
+ { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
516
+ { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
517
+ { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
518
+ { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
519
+ { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
520
+ { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
521
+ { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
522
+ { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
523
+ { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
524
+ { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
525
+ { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
526
+ { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
527
+ { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
528
+ { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
529
+ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
530
+ { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
531
+ { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
532
+ { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
533
+ { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
534
+ { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
535
+ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
536
  ]
537
 
538
  [[package]]
 
849
 
850
  [[package]]
851
  name = "huggingface-hub"
852
+ version = "1.9.2"
853
  source = { registry = "https://pypi.org/simple" }
854
  dependencies = [
855
  { name = "filelock" },
 
862
  { name = "typer" },
863
  { name = "typing-extensions" },
864
  ]
865
+ sdist = { url = "https://files.pythonhosted.org/packages/cf/65/fb800d327bf25bf31b798dd08935d326d064ecb9b359059fecd91b3a98e8/huggingface_hub-1.9.2.tar.gz", hash = "sha256:8d09d080a186bd950a361bfc04b862dfb04d6a2b41d48e9ba1b37507cfd3f1e1", size = 750284, upload-time = "2026-04-08T08:43:11.127Z" }
866
  wheels = [
867
+ { url = "https://files.pythonhosted.org/packages/57/d4/e33bf0b362810a9b96c5923e38908950d58ecb512db42e3730320c7f4a3a/huggingface_hub-1.9.2-py3-none-any.whl", hash = "sha256:e1e62ce237d4fbeca9f970aeb15176fbd503e04c25577bfd22f44aa7aa2b5243", size = 637349, upload-time = "2026-04-08T08:43:09.114Z" },
868
  ]
869
 
870
  [[package]]
 
1481
  ]
1482
 
1483
  [[package]]
1484
+ name = "openenv-eco-llm-inference-routing"
1485
  version = "0.1.0"
1486
  source = { editable = "." }
1487
  dependencies = [
1488
+ { name = "fastapi" },
1489
+ { name = "gradio" },
1490
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
1491
+ { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
1492
+ { name = "openai" },
1493
  { name = "openenv-core", extra = ["core"] },
1494
+ { name = "pydantic" },
1495
+ { name = "uvicorn" },
1496
  ]
1497
 
1498
  [package.optional-dependencies]
1499
  dev = [
1500
  { name = "pytest" },
 
1501
  ]
1502
 
1503
  [package.metadata]
1504
  requires-dist = [
1505
+ { name = "fastapi", specifier = ">=0.115.0" },
1506
+ { name = "gradio", specifier = ">=4.0.0" },
1507
+ { name = "numpy", specifier = ">=1.26.0" },
1508
+ { name = "openai", specifier = ">=1.40.0" },
1509
  { name = "openenv-core", extras = ["core"], specifier = ">=0.2.2" },
1510
+ { name = "pydantic", specifier = ">=2.8.0" },
1511
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
1512
+ { name = "uvicorn", specifier = ">=0.30.0" },
1513
  ]
1514
  provides-extras = ["dev"]
1515
 
 
2108
 
2109
  [[package]]
2110
  name = "pytest"
2111
+ version = "9.0.3"
2112
  source = { registry = "https://pypi.org/simple" }
2113
  dependencies = [
2114
  { name = "colorama", marker = "sys_platform == 'win32'" },
 
2119
  { name = "pygments" },
2120
  { name = "tomli", marker = "python_full_version < '3.11'" },
2121
  ]
2122
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2123
  wheels = [
2124
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
2125
  ]
2126
 
2127
  [[package]]
 
2147
 
2148
  [[package]]
2149
  name = "python-multipart"
2150
+ version = "0.0.24"
2151
  source = { registry = "https://pypi.org/simple" }
2152
+ sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" }
2153
  wheels = [
2154
+ { url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" },
2155
  ]
2156
 
2157
  [[package]]
 
2653
 
2654
  [[package]]
2655
  name = "uncalled-for"
2656
+ version = "0.3.1"
2657
  source = { registry = "https://pypi.org/simple" }
2658
+ sdist = { url = "https://files.pythonhosted.org/packages/e1/68/35c1d87e608940badbcfeb630347aa0509897284684f61fab6423d02b253/uncalled_for-0.3.1.tar.gz", hash = "sha256:5e412ac6708f04b56bef5867b5dcf6690ebce4eb7316058d9c50787492bb4bca", size = 49693, upload-time = "2026-04-07T13:05:06.462Z" }
2659
  wheels = [
2660
+ { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" },
2661
  ]
2662
 
2663
  [[package]]
 
2671
 
2672
  [[package]]
2673
  name = "uvicorn"
2674
+ version = "0.44.0"
2675
  source = { registry = "https://pypi.org/simple" }
2676
  dependencies = [
2677
  { name = "click" },
2678
  { name = "h11" },
2679
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
2680
  ]
2681
+ sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
2682
  wheels = [
2683
+ { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
2684
  ]
2685
 
2686
  [[package]]