diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index 0aae882..c3b5387 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -5,22 +5,30 @@ alwaysApply: true # Architecture -- `domain.py` holds all data structures (not `models.py`, which is ambiguous in an LLM project) -- `openrouter.py` is the HTTP client layer (separate from orchestration) -- `simulation.py` holds all dry-run fakes (LLM responses and search results) -- `prompts.py` contains prompt builders (pure functions) and output parsers (review verdict, synthesis decision) -- `compression.py` handles both low-level text compression and prompt fitting within token budgets -- `progress.py` defines the `ProgressCallback` protocol and `NoOpProgress` fallback -- `reviewers.py` holds the reviewer-to-candidate assignment algorithm -- `exclamations.py` holds the Simpsons-quote prefix helper used to dress up error messages -- Cross-group model overlap is allowed: a model may generate and review in the same round -- Compression priority: candidates first, then reviews, then context; the task instruction is never compressed - -## Dependency direction +## One rule - `cli.py` depends on `core/` and `ui/`; nothing in `core/` or `ui/` imports from `cli.py` -- `ui/tui.py` depends on `core/progress.py` (protocol) and `core/domain.py` (types); it never imports from `core/orchestrator.py` -- Within `core/`, `orchestrator.py` is the only module that imports from `openrouter.py`, `compression.py`, and `reviewers.py`; `search.py` is imported by `orchestrator.py` only but may itself import `simulation.py` for its dry-run path -- `prompts.py` depends only on `domain.py`; it has no runtime dependencies on other core modules -- `exclamations.py` is a leaf module (stdlib-only); it may be imported from anywhere in `core/` -- `config.py` loads `crossfire.toml` with CLI override precedence: CLI flags > TOML values > defaults; per-mode overrides go in `[modes..*]`, missing roles fall back to global `[models.*]` defaults + +Within `core/`, modules import freely from each other as needed. + +## Module responsibilities + +- `domain.py` — all data structures (not `models.py`, which is ambiguous in an LLM project) +- `orchestrator.py` — the generate → review → synthesize loop, concurrency, failure handling +- `openrouter.py` — OpenRouter HTTP client, retry logic, model ID utilities +- `prompts.py` — prompt builders (pure functions) and output parsers (review verdict, synthesis decision) +- `compression.py` — extractive text compression and prompt fitting within token budgets +- `config.py` — TOML loading with CLI override precedence: CLI flags > TOML values > defaults; per-mode overrides in `[modes..*]` +- `pricing.py` — OpenRouter pricing cache (`pricing.json`) and cost estimation for dry runs +- `simulation.py` — deterministic fakes for dry-run mode (LLM responses and search results) +- `search.py` — Tavily web search integration +- `reviewers.py` — reviewer-to-candidate assignment algorithm +- `tokens.py` — tiktoken-based token estimation +- `progress.py` — `ProgressCallback` protocol and `NoOpProgress` fallback +- `exclamations.py` — Simpsons-quote prefix helper for error messages +- `archive.py` — disk archival of run artifacts + +## Design decisions + +- Cross-group model overlap is allowed: a model may generate and review in the same round +- Compression priority: candidates first, then reviews, then context; the task instruction is never compressed diff --git a/.cursor/rules/planning.mdc b/.cursor/rules/planning.mdc new file mode 100644 index 0000000..b1f9960 --- /dev/null +++ b/.cursor/rules/planning.mdc @@ -0,0 +1,17 @@ +--- +description: Plan validation — end-to-end trace, contradiction check, edge cases +alwaysApply: true +--- + +# Planning + +Before finalizing any plan that touches 3+ files: + +- Trace every data flow end-to-end through the actual code (not the abstraction): verify function signatures, call sites, and who calls whom +- Check that the plan is internally consistent: no section may contradict another +- Identify every existing function, configuration object, or data structure the new code depends on and verify it provides what the plan assumes (e.g. which configuration variant: base vs mode-resolved?) +- For every external input (files, API responses, environment variables), list what happens when it is missing, empty, malformed, or the wrong type +- Flag every assumption about the runtime context: sync vs async, working directory, which process or event loop, which thread +- Separate input estimates from output estimates: never use the same ceiling for both unless explicitly justified +- After drafting the plan, re-read it as a hostile reviewer looking for gaps, contradictions, and unstated assumptions; fix before presenting +- After implementation, verify the code matches the plan: trace actual variable values through each formula and code path, checking that upstream mutations (e.g. enrichment rewriting an instruction) are reflected in every downstream use of that variable diff --git a/README.md b/README.md index 1d9bb1c..8b91438 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,16 @@ uv run crossfire run \ --context-file paper.pdf ``` +### Cost estimation +Cost estimation in ``--dry-run`` requires current model prices from OpenRouter. +These can be grabbed and stored in `pricing.json` with the following command: + +```bash +uv run crossfire prices +``` + +Since it fetches pricing on _all_ OpenRouter models, we can add moves to `crossfire.toml` without re-fetching. + ### Clean up Remove all generated and cached files (runs, `.venv`, caches, bytecode): @@ -152,7 +162,7 @@ After each round, Crossfire checks whether any reviewer found material weaknesse If all reviews report no weaknesses, the remaining rounds are skipped, as there is no value in further refinement. Disable with `--no-early-stop`. -### Generator refusal +### Generator refusal detection Some models occasionally refuse to produce output, responding with meta-commentary like "the sources are insufficient" instead of answering the prompt. Crossfire detects these refusals and attempts a replacement model from the generator pool. If no replacement is available, the generator is dropped and the round fails gracefully. @@ -228,6 +238,7 @@ uv run mypy crossfire/ # type check Use `--dry-run` to verify your changes without making API calls. It produces deterministic synthetic outputs via SHA-256 hashing. +If `pricing.json` is present (from `crossfire prices`), the summary table includes an upper-bound cost estimate. ```bash uv run crossfire run \ @@ -267,6 +278,7 @@ crossfire/ │ │ ├── progress.py # progress reporting │ │ ├── reviewers.py # reviewer-to-candidate assignment │ │ ├── search.py # search integration with Tavily +│ │ ├── pricing.py # OpenRouter pricing cache and cost estimation │ │ ├── exclamations.py # The Simpsons prefixes for error messages │ │ └── archive.py # disk archival │ ├── ui/ @@ -296,6 +308,11 @@ Transient errors degrade gracefully to empty results rather than aborting the ru When prompts exceed the token budget, Crossfire drops sections and sentences rather than summarizing. The task instruction is _never_ compressed. +**Cost estimates are approximate.** +The dry-run estimate uses fixed output-token defaults (~5,000 tokens per generator/synthesizer call, ~2,000 per reviewer) and average pricing across each model group. +It does not predict early stopping or actual output lengths, so it typically overestimates by 2-4x for runs that stop early. +If the instruction contains an explicit word or page count, the estimate uses that instead, but the regex may also match counts that describe the input rather than the desired output. + **No streaming.** Responses are received in full. @@ -305,6 +322,5 @@ Each run is self-contained, so a crashed run must be restarted from scratch. ### Ideas for improvements - Resume: restart interrupted runs from the last completed round -- Cost prediction: estimate total run costs upfront from prompt sizes and model pricing, reported as part of `--dry-run` output - Local UI: browser-based interface with live progress, output panel, and searchable run history - Library and containerization: extract a reusable library and Docker image so Crossfire can run as a service (switch to UTC-based logs) \ No newline at end of file diff --git a/crossfire.toml b/crossfire.toml index a24d594..2c8361c 100644 --- a/crossfire.toml +++ b/crossfire.toml @@ -12,8 +12,7 @@ # Per-mode [modes.*] sections override the global [models.*] defaults. # Only roles that differ from the global need to be specified. # -# Prices are per 1M tokens (input/output) as of April 2026. -# Check https://openrouter.ai/models for current rates. +# Run 'crossfire prices' to fetch current rates into pricing.json. # ============================================================================ [openrouter] @@ -25,11 +24,11 @@ api_key_env = "OPENROUTER_API_KEY" [models.generators] names = [ - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — strong all-rounder - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — near-GPT-5 quality, but dirt cheap - "openrouter:openai/gpt-4.1", # $2/$8 — solid yet cheap - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest, good for diversity - "openrouter:mistralai/devstral-2512", # $0.40/$2 — agentic code specialist + "openrouter:anthropic/claude-sonnet-4", # strong all-rounder + "openrouter:deepseek/deepseek-v3.2", # near-GPT-5 quality, but dirt cheap + "openrouter:openai/gpt-4.1", # solid yet cheap + "openrouter:meta-llama/llama-4-maverick", # cheapest, good for diversity + "openrouter:mistralai/devstral-2512", # agentic code specialist ] context_window = 200000 max_output_tokens = 12000 @@ -44,21 +43,21 @@ max_output_tokens = 12000 # Reasoning models and blunt critics matter more than raw generation power. # 15 models supports up to 5 generators * 3 reviewers per candidate. names = [ - "openrouter:openai/o4-mini", # $1.10/$4.40 — critical reasoning - "openrouter:google/gemini-3.1-flash-lite", # $0.25/$1.50 — fast, 1M context - "openrouter:mistralai/mistral-medium-3", # $0.40/$2 — cheap generalist - "openrouter:x-ai/grok-3-mini", # $0.30/$0.50 — concise, opinionated - "openrouter:qwen/qwen-plus-2025-07-28", # $0.26/$0.78 — hybrid reasoning - "openrouter:openai/gpt-4.1-mini", # $0.40/$1.60 — cheap, fast, competent - "openrouter:anthropic/claude-haiku-4.5", # $1/$5 — fast critic - "openrouter:google/gemini-2.5-flash", # $0.30/$2.50 — 1M context, reasoning - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — blunt yet detail-oriented - "openrouter:mistralai/devstral-2512", # $0.40/$2 — code-aware perspective - "openrouter:google/gemini-2.5-pro-preview-03-25", # $1.25/$10 — verbose but thorough - "openrouter:perplexity/sonar-reasoning-pro", # $2/$8 — reasoning + search - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest structure checker - "openrouter:openai/gpt-4.1", # $2/$8 — solid generalist - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — thorough but expensive + "openrouter:openai/o4-mini", # critical reasoning + "openrouter:google/gemini-3.1-flash-lite", # fast, 1M context + "openrouter:mistralai/mistral-medium-3", # cheap generalist + "openrouter:x-ai/grok-3-mini", # concise, opinionated + "openrouter:qwen/qwen-plus-2025-07-28", # hybrid reasoning + "openrouter:openai/gpt-4.1-mini", # cheap, fast, competent + "openrouter:anthropic/claude-haiku-4.5", # fast critic + "openrouter:google/gemini-2.5-flash", # 1M context, reasoning + "openrouter:deepseek/deepseek-v3.2", # blunt yet detail-oriented + "openrouter:mistralai/devstral-2512", # code-aware perspective + "openrouter:google/gemini-2.5-pro-preview-03-25", # verbose but thorough + "openrouter:perplexity/sonar-reasoning-pro", # reasoning + search + "openrouter:meta-llama/llama-4-maverick", # cheapest structure checker + "openrouter:openai/gpt-4.1", # solid generalist + "openrouter:anthropic/claude-sonnet-4", # thorough but expensive ] context_window = 128000 max_output_tokens = 8000 @@ -80,14 +79,14 @@ max_output_tokens = 8000 "openrouter:deepseek/deepseek-v3.2" = 6000 [models.enricher] -names = ["openrouter:openai/gpt-4.1-mini"] # $0.40/$1.60 — cheap, fast, good at expansion +names = ["openrouter:openai/gpt-4.1-mini"] # cheap, fast, good at expansion context_window = 128000 max_output_tokens = 4096 [models.synthesizer] names = [ - "openrouter:anthropic/claude-opus-4", # $15/$75 — best multi-doc reasoning - "openrouter:openai/gpt-5", # $1.25/$10 — strong reasoning, 400K context + "openrouter:anthropic/claude-opus-4", # best multi-doc reasoning + "openrouter:openai/gpt-5", # strong reasoning, 400K context ] context_window = 200000 max_output_tokens = 32000 @@ -116,11 +115,11 @@ temperature_default = 0.2 # Reviewers must not overlap with generators! [modes.research.generators] names = [ - "openrouter:perplexity/sonar-pro", # $3/$15 — grounded search, real citations - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — cheap analytical angle - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — structured analysis - "openrouter:openai/gpt-4.1", # $2/$8 — solid researcher - "openrouter:perplexity/sonar-deep-research", # $2/$8 — multi-step deep research + "openrouter:perplexity/sonar-pro", # grounded search, real citations + "openrouter:deepseek/deepseek-v3.2", # cheap analytical angle + "openrouter:anthropic/claude-sonnet-4", # structured analysis + "openrouter:openai/gpt-4.1", # solid researcher + "openrouter:perplexity/sonar-deep-research", # multi-step deep research ] context_window = 200000 max_output_tokens = 12000 @@ -134,21 +133,21 @@ max_output_tokens = 12000 # 15 reviewers: supports up to 5 generators * 3 reviewers per candidate. # None overlap with the research generators above. names = [ - "openrouter:openai/o4-mini", # $1.10/$4.40 — reasoning claim checker - "openrouter:google/gemini-3.1-flash-lite", # $0.25/$1.50 — fast, cheap - "openrouter:mistralai/mistral-medium-3", # $0.40/$2 — generalist - "openrouter:x-ai/grok-3-mini", # $0.30/$0.50 — concise critic - "openrouter:qwen/qwen-plus-2025-07-28", # $0.26/$0.78 — hybrid reasoner - "openrouter:openai/gpt-4.1-mini", # $0.40/$1.60 — cheap, fast - "openrouter:anthropic/claude-haiku-4.5", # $1/$5 — fast Anthropic critic - "openrouter:google/gemini-2.5-flash", # $0.30/$2.50 — reasoning flash - "openrouter:mistralai/devstral-2512", # $0.40/$2 — different perspective - "openrouter:google/gemini-2.5-pro-preview-03-25", # $1.25/$10 — verbose but thorough - "openrouter:perplexity/sonar-reasoning-pro", # $2/$8 — reasoning + search - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest - "openrouter:openai/gpt-4o-mini", # $0.15/$0.60 — tiny, fast - "openrouter:deepseek/deepseek-r1", # $0.55/$2.19 — reasoning specialist - "openrouter:google/gemma-3-27b-it", # free — zero-cost filler + "openrouter:openai/o4-mini", # reasoning claim checker + "openrouter:google/gemini-3.1-flash-lite", # fast, cheap + "openrouter:mistralai/mistral-medium-3", # generalist + "openrouter:x-ai/grok-3-mini", # concise critic + "openrouter:qwen/qwen-plus-2025-07-28", # hybrid reasoner + "openrouter:openai/gpt-4.1-mini", # cheap, fast + "openrouter:anthropic/claude-haiku-4.5", # fast Anthropic critic + "openrouter:google/gemini-2.5-flash", # reasoning flash + "openrouter:mistralai/devstral-2512", # different perspective + "openrouter:google/gemini-2.5-pro-preview-03-25", # verbose but thorough + "openrouter:perplexity/sonar-reasoning-pro", # reasoning + search + "openrouter:meta-llama/llama-4-maverick", # cheapest + "openrouter:openai/gpt-4o-mini", # tiny, fast + "openrouter:deepseek/deepseek-r1", # reasoning specialist + "openrouter:google/gemma-3-27b-it", # zero-cost filler ] context_window = 128000 max_output_tokens = 8000 @@ -160,11 +159,11 @@ max_output_tokens = 8000 # o3 synthesizes code via reasoning. [modes.code.generators] names = [ - "openrouter:openai/gpt-5.3-codex-20260224", # $1.75/$14 — SOTA agentic code - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — GPT-5 class code, dirt cheap - "openrouter:openai/gpt-4.1", # $2/$8 — solid coder - "openrouter:mistralai/devstral-2512", # $0.40/$2 — agentic multi-file code - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — strong code generation + "openrouter:openai/gpt-5.3-codex-20260224", # SOTA agentic code + "openrouter:deepseek/deepseek-v3.2", # GPT-5 class code, dirt cheap + "openrouter:openai/gpt-4.1", # solid coder + "openrouter:mistralai/devstral-2512", # agentic multi-file code + "openrouter:anthropic/claude-sonnet-4", # strong code generation ] context_window = 200000 max_output_tokens = 12000 @@ -175,27 +174,27 @@ max_output_tokens = 12000 [modes.code.reviewers] names = [ - "openrouter:openai/o4-mini", # $1.10/$4.40 — finds logic bugs - "openrouter:google/gemini-3.1-flash-lite", # $0.25/$1.50 — fast structural check - "openrouter:mistralai/mistral-medium-3", # $0.40/$2 — generalist - "openrouter:x-ai/grok-3-mini", # $0.30/$0.50 — concise - "openrouter:qwen/qwen-plus-2025-07-28", # $0.26/$0.78 — hybrid reasoner - "openrouter:openai/gpt-4.1-mini", # $0.40/$1.60 — cheap - "openrouter:anthropic/claude-haiku-4.5", # $1/$5 — fast code reviewer - "openrouter:google/gemini-2.5-flash", # $0.30/$2.50 — reasoning flash - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — structure/formatting - "openrouter:google/gemini-2.5-pro-preview-03-25", # $1.25/$10 — thorough - "openrouter:perplexity/sonar-reasoning-pro", # $2/$8 — reasoning - "openrouter:deepseek/deepseek-r1", # $0.55/$2.19 — reasoning specialist - "openrouter:openai/gpt-4o-mini", # $0.15/$0.60 — tiny, fast - "openrouter:google/gemma-3-27b-it", # free — zero-cost filler - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — thorough code review + "openrouter:openai/o4-mini", # finds logic bugs + "openrouter:google/gemini-3.1-flash-lite", # fast structural check + "openrouter:mistralai/mistral-medium-3", # generalist + "openrouter:x-ai/grok-3-mini", # concise + "openrouter:qwen/qwen-plus-2025-07-28", # hybrid reasoner + "openrouter:openai/gpt-4.1-mini", # cheap + "openrouter:anthropic/claude-haiku-4.5", # fast code reviewer + "openrouter:google/gemini-2.5-flash", # reasoning flash + "openrouter:meta-llama/llama-4-maverick", # structure/formatting + "openrouter:google/gemini-2.5-pro-preview-03-25", # thorough + "openrouter:perplexity/sonar-reasoning-pro", # reasoning + "openrouter:deepseek/deepseek-r1", # reasoning specialist + "openrouter:openai/gpt-4o-mini", # tiny, fast + "openrouter:google/gemma-3-27b-it", # zero-cost filler + "openrouter:anthropic/claude-sonnet-4", # thorough code review ] context_window = 200000 max_output_tokens = 8000 [modes.code.synthesizer] -names = ["openrouter:openai/o3"] # $10/$40 — reasoning model for code merge +names = ["openrouter:openai/o3"] # reasoning model for code merge context_window = 200000 max_output_tokens = 32000 @@ -203,11 +202,11 @@ max_output_tokens = 32000 # Editing is lighter work. Sonnet synthesizes edits cheaply. [modes.edit.generators] names = [ - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — concise, decisive editor - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — cheap - "openrouter:openai/gpt-4.1", # $2/$8 — good editor - "openrouter:mistralai/devstral-2512", # $0.40/$2 — different style - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest + "openrouter:anthropic/claude-sonnet-4", # concise, decisive editor + "openrouter:deepseek/deepseek-v3.2", # cheap + "openrouter:openai/gpt-4.1", # good editor + "openrouter:mistralai/devstral-2512", # different style + "openrouter:meta-llama/llama-4-maverick", # cheapest ] context_window = 200000 max_output_tokens = 12000 @@ -219,27 +218,27 @@ max_output_tokens = 12000 [modes.edit.reviewers] names = [ - "openrouter:openai/o4-mini", # $1.10/$4.40 — clarity critique - "openrouter:google/gemini-3.1-flash-lite", # $0.25/$1.50 — fast - "openrouter:mistralai/mistral-medium-3", # $0.40/$2 — generalist - "openrouter:x-ai/grok-3-mini", # $0.30/$0.50 — concise - "openrouter:qwen/qwen-plus-2025-07-28", # $0.26/$0.78 — hybrid reasoner - "openrouter:openai/gpt-4.1-mini", # $0.40/$1.60 — cheap - "openrouter:anthropic/claude-haiku-4.5", # $1/$5 — fast - "openrouter:google/gemini-2.5-flash", # $0.30/$2.50 — reasoning flash - "openrouter:google/gemini-2.5-pro-preview-03-25", # $1.25/$10 — verbose (useful for editing) - "openrouter:perplexity/sonar-reasoning-pro", # $2/$8 — reasoning - "openrouter:deepseek/deepseek-r1", # $0.55/$2.19 — reasoning specialist - "openrouter:openai/gpt-4o-mini", # $0.15/$0.60 — tiny - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest - "openrouter:google/gemma-3-27b-it", # free — zero-cost filler - "openrouter:openai/gpt-4.1", # $2/$8 — solid + "openrouter:openai/o4-mini", # clarity critique + "openrouter:google/gemini-3.1-flash-lite", # fast + "openrouter:mistralai/mistral-medium-3", # generalist + "openrouter:x-ai/grok-3-mini", # concise + "openrouter:qwen/qwen-plus-2025-07-28", # hybrid reasoner + "openrouter:openai/gpt-4.1-mini", # cheap + "openrouter:anthropic/claude-haiku-4.5", # fast + "openrouter:google/gemini-2.5-flash", # reasoning flash + "openrouter:google/gemini-2.5-pro-preview-03-25", # verbose (useful for editing) + "openrouter:perplexity/sonar-reasoning-pro", # reasoning + "openrouter:deepseek/deepseek-r1", # reasoning specialist + "openrouter:openai/gpt-4o-mini", # tiny + "openrouter:meta-llama/llama-4-maverick", # cheapest + "openrouter:google/gemma-3-27b-it", # zero-cost filler + "openrouter:openai/gpt-4.1", # solid ] context_window = 128000 max_output_tokens = 8000 [modes.edit.synthesizer] -names = ["openrouter:anthropic/claude-sonnet-4"] # $3/$15 — editing doesn't need Opus +names = ["openrouter:anthropic/claude-sonnet-4"] # editing doesn't need Opus context_window = 200000 max_output_tokens = 12000 @@ -247,11 +246,11 @@ max_output_tokens = 12000 # Accuracy demands strong synthesis. Perplexity for grounding. [modes.check.generators] names = [ - "openrouter:openai/gpt-4.1", # $2/$8 — methodical - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — cheap - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — structured - "openrouter:perplexity/sonar-pro", # $3/$15 — grounded search for fact-check - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest + "openrouter:openai/gpt-4.1", # methodical + "openrouter:deepseek/deepseek-v3.2", # cheap + "openrouter:anthropic/claude-sonnet-4", # structured + "openrouter:perplexity/sonar-pro", # grounded search for fact-check + "openrouter:meta-llama/llama-4-maverick", # cheapest ] context_window = 200000 max_output_tokens = 12000 @@ -263,21 +262,21 @@ max_output_tokens = 12000 [modes.check.reviewers] names = [ - "openrouter:openai/o4-mini", # $1.10/$4.40 — logic validation - "openrouter:google/gemini-3.1-flash-lite", # $0.25/$1.50 — fast - "openrouter:mistralai/mistral-medium-3", # $0.40/$2 — generalist - "openrouter:x-ai/grok-3-mini", # $0.30/$0.50 — concise - "openrouter:qwen/qwen-plus-2025-07-28", # $0.26/$0.78 — hybrid reasoner - "openrouter:openai/gpt-4.1-mini", # $0.40/$1.60 — cheap - "openrouter:anthropic/claude-haiku-4.5", # $1/$5 — fast - "openrouter:google/gemini-2.5-flash", # $0.30/$2.50 — reasoning flash - "openrouter:mistralai/devstral-2512", # $0.40/$2 — different perspective - "openrouter:google/gemini-2.5-pro-preview-03-25", # $1.25/$10 — thorough - "openrouter:perplexity/sonar-reasoning-pro", # $2/$8 — reasoning + search - "openrouter:deepseek/deepseek-r1", # $0.55/$2.19 — reasoning specialist - "openrouter:openai/gpt-4o-mini", # $0.15/$0.60 — tiny - "openrouter:google/gemma-3-27b-it", # free — zero-cost filler - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest + "openrouter:openai/o4-mini", # logic validation + "openrouter:google/gemini-3.1-flash-lite", # fast + "openrouter:mistralai/mistral-medium-3", # generalist + "openrouter:x-ai/grok-3-mini", # concise + "openrouter:qwen/qwen-plus-2025-07-28", # hybrid reasoner + "openrouter:openai/gpt-4.1-mini", # cheap + "openrouter:anthropic/claude-haiku-4.5", # fast + "openrouter:google/gemini-2.5-flash", # reasoning flash + "openrouter:mistralai/devstral-2512", # different perspective + "openrouter:google/gemini-2.5-pro-preview-03-25", # thorough + "openrouter:perplexity/sonar-reasoning-pro", # reasoning + search + "openrouter:deepseek/deepseek-r1", # reasoning specialist + "openrouter:openai/gpt-4o-mini", # tiny + "openrouter:google/gemma-3-27b-it", # zero-cost filler + "openrouter:meta-llama/llama-4-maverick", # cheapest ] context_window = 128000 max_output_tokens = 8000 @@ -288,11 +287,11 @@ max_output_tokens = 8000 # Creative voice needs diverse generators. Sonnet synthesizes to preserve voice. [modes.write.generators] names = [ - "openrouter:anthropic/claude-sonnet-4", # $3/$15 — strong voice - "openrouter:deepseek/deepseek-v3.2", # $0.26/$0.38 — cheap, different tone - "openrouter:google/gemini-2.5-pro-preview-03-25", # $1.25/$10 — verbose (a feature for writing) - "openrouter:openai/gpt-4.1", # $2/$8 — solid - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest + "openrouter:anthropic/claude-sonnet-4", # strong voice + "openrouter:deepseek/deepseek-v3.2", # cheap, different tone + "openrouter:google/gemini-2.5-pro-preview-03-25", # verbose (a feature for writing) + "openrouter:openai/gpt-4.1", # solid + "openrouter:meta-llama/llama-4-maverick", # cheapest ] context_window = 200000 max_output_tokens = 12000 @@ -304,26 +303,26 @@ max_output_tokens = 12000 [modes.write.reviewers] names = [ - "openrouter:openai/gpt-4.1", # $2/$8 — tough literary critic - "openrouter:google/gemini-3.1-flash-lite", # $0.25/$1.50 — fast - "openrouter:openai/o4-mini", # $1.10/$4.40 — structural critique - "openrouter:x-ai/grok-3-mini", # $0.30/$0.50 — concise - "openrouter:mistralai/mistral-medium-3", # $0.40/$2 — generalist - "openrouter:qwen/qwen-plus-2025-07-28", # $0.26/$0.78 — hybrid reasoner - "openrouter:openai/gpt-4.1-mini", # $0.40/$1.60 — cheap - "openrouter:anthropic/claude-haiku-4.5", # $1/$5 — fast - "openrouter:google/gemini-2.5-flash", # $0.30/$2.50 — reasoning flash - "openrouter:mistralai/devstral-2512", # $0.40/$2 — different perspective - "openrouter:perplexity/sonar-reasoning-pro", # $2/$8 — reasoning - "openrouter:deepseek/deepseek-r1", # $0.55/$2.19 — reasoning specialist - "openrouter:openai/gpt-4o-mini", # $0.15/$0.60 — tiny - "openrouter:google/gemma-3-27b-it", # free — zero-cost filler - "openrouter:meta-llama/llama-4-maverick", # $0.15/$0.60 — cheapest + "openrouter:openai/gpt-4.1", # tough literary critic + "openrouter:google/gemini-3.1-flash-lite", # fast + "openrouter:openai/o4-mini", # structural critique + "openrouter:x-ai/grok-3-mini", # concise + "openrouter:mistralai/mistral-medium-3", # generalist + "openrouter:qwen/qwen-plus-2025-07-28", # hybrid reasoner + "openrouter:openai/gpt-4.1-mini", # cheap + "openrouter:anthropic/claude-haiku-4.5", # fast + "openrouter:google/gemini-2.5-flash", # reasoning flash + "openrouter:mistralai/devstral-2512", # different perspective + "openrouter:perplexity/sonar-reasoning-pro", # reasoning + "openrouter:deepseek/deepseek-r1", # reasoning specialist + "openrouter:openai/gpt-4o-mini", # tiny + "openrouter:google/gemma-3-27b-it", # zero-cost filler + "openrouter:meta-llama/llama-4-maverick", # cheapest ] context_window = 200000 max_output_tokens = 8000 [modes.write.synthesizer] -names = ["openrouter:anthropic/claude-sonnet-4"] # $3/$15 — voice preservation +names = ["openrouter:anthropic/claude-sonnet-4"] # voice preservation context_window = 200000 max_output_tokens = 12000 diff --git a/crossfire/cli.py b/crossfire/cli.py index c622a5e..f76e645 100644 --- a/crossfire/cli.py +++ b/crossfire/cli.py @@ -1,4 +1,4 @@ -"""CLI for ``crossfire run`` and ``crossfire clean``.""" +"""CLI for ``crossfire run``, ``crossfire clean``, and ``crossfire prices``.""" from __future__ import annotations @@ -6,16 +6,24 @@ import logging as _logging import shutil import sys -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path import click from crossfire.core.archive import RunArchive from crossfire.core.config import load_configuration -from crossfire.core.domain import CrossfireConfiguration, Mode, RunParameters, Task +from crossfire.core.domain import CostEstimate, CrossfireConfiguration, Mode, RunParameters, Task from crossfire.core.logging import set_stderr_level from crossfire.core.orchestrator import Orchestrator, RunFailedError +from crossfire.core.pricing import ( + PRICING_FILENAME, + estimate_cost, + fetch_pricing, + load_pricing, + parse_api_response, + save_pricing, +) from crossfire.core.search import get_search_api_key from crossfire.ui.tui import TUI @@ -156,10 +164,16 @@ def run( click.echo(f"Configuration error: {exception}", err=True) sys.exit(1) + cost_estimate: CostEstimate | None = None + if parameters.dry_run: + cost_estimate = _try_estimate_cost(configuration, parameters) + archive_path: Path = Path(run_dir or f"runs/{datetime.now().strftime('%Y-%m-%dT%H-%M-%S')}") try: - result = asyncio.run(_execute(configuration, parameters, archive_path, verbose=verbose)) + result = asyncio.run( + _execute(configuration, parameters, archive_path, verbose=verbose, cost_estimate=cost_estimate) + ) except RunFailedError as exception: click.echo(f"Run failed: {exception}", err=True) sys.exit(1) @@ -215,12 +229,57 @@ def clean() -> None: click.echo("Nothing to clean.") +@cli.command() +def prices() -> None: + """Fetches current model pricing from OpenRouter and writes pricing.json.""" + if not Path("crossfire.toml").is_file(): + click.echo( + "No can do! There is no crossfire.toml in the current directory. " "Run this from the project root.", + err=True, + ) + sys.exit(1) + + click.echo("Fetching pricing from OpenRouter...", err=True) + try: + raw = fetch_pricing() + except Exception as exception: + click.echo(f"Failed to fetch pricing: {exception}", err=True) + sys.exit(1) + + pricing = parse_api_response(raw) + fetched_at: str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + output_path = Path(PRICING_FILENAME) + save_pricing(pricing, fetched_at, output_path) + click.echo(f"Saved pricing for {len(pricing)} models to {output_path}", err=True) + + +def _try_estimate_cost( + configuration: CrossfireConfiguration, + parameters: RunParameters, +) -> CostEstimate | None: + """Loads pricing.json and returns a cost estimate, or None if the file is missing.""" + pricing_path = Path(PRICING_FILENAME) + if not pricing_path.is_file(): + click.echo( + "No pricing data found. Run 'crossfire prices' to enable cost estimates.", + err=True, + ) + return None + try: + pricing, fetched_at = load_pricing(pricing_path) + except (ValueError, OSError) as exception: + click.echo(f"Could not load {PRICING_FILENAME}: {exception}", err=True) + return None + return estimate_cost(configuration, parameters, pricing, fetched_at) + + async def _execute( configuration: CrossfireConfiguration, parameters: RunParameters, archive_path: Path, *, verbose: bool = False, + cost_estimate: CostEstimate | None = None, ) -> str: """Bootstraps the TUI (unless verbose), runs the orchestrator, and returns the final output.""" archive: RunArchive = RunArchive(archive_path) @@ -235,7 +294,9 @@ async def _execute( orchestrator = Orchestrator(configuration, parameters, progress=tui, archive=archive) result: str = await orchestrator.run() if tui: - tui.finish(orchestrator.cost_tracker.summarize()) + tui.finish(orchestrator.cost_tracker.summarize(), cost_estimate=cost_estimate) + elif cost_estimate is not None: + _print_cost_estimate(cost_estimate) return result except Exception: if tui: @@ -243,6 +304,13 @@ async def _execute( raise +def _print_cost_estimate(cost_estimate: CostEstimate) -> None: + """Prints the cost estimate to stderr (verbose mode fallback when TUI is not active).""" + click.echo(f"Estimated cost: ${cost_estimate.total_usd:.2f}", err=True) + if cost_estimate.fetched_at: + click.echo(f"Prices from: {cost_estimate.fetched_at[:10]}", err=True) + + def main() -> None: """Entrypoint for the ``crossfire`` console script.""" cli() diff --git a/crossfire/core/archive.py b/crossfire/core/archive.py index b322151..ce591c7 100644 --- a/crossfire/core/archive.py +++ b/crossfire/core/archive.py @@ -9,13 +9,14 @@ from crossfire.core import logging as log from crossfire.core.domain import Candidate, Review, RunParameters, SynthesisResult +from crossfire.core.openrouter import strip_model_prefix _UNSAFE_FILENAME_CHARS_REGEX = re.compile(r"[^A-Za-z0-9._-]") def _sanitize_model_name(model: str) -> str: """Converts a model ID to a filesystem-safe string.""" - return _UNSAFE_FILENAME_CHARS_REGEX.sub("_", model.removeprefix("openrouter:")) + return _UNSAFE_FILENAME_CHARS_REGEX.sub("_", strip_model_prefix(model)) class RunArchive: diff --git a/crossfire/core/domain.py b/crossfire/core/domain.py index 1739e73..41be96d 100644 --- a/crossfire/core/domain.py +++ b/crossfire/core/domain.py @@ -295,6 +295,15 @@ class RunParameters: early_stop_threshold: int = 1 +@dataclass(frozen=True) +class CostEstimate: + """Upper-bound cost estimate for a dry run, based on cached OpenRouter pricing.""" + + total_usd: float + missing_models: tuple[str, ...] = () + fetched_at: str = "" + + @dataclass class CostEntry: """Token and cost information from a single LLM call in OpenRouter.""" diff --git a/crossfire/core/openrouter.py b/crossfire/core/openrouter.py index 53a7249..0ee078b 100644 --- a/crossfire/core/openrouter.py +++ b/crossfire/core/openrouter.py @@ -14,6 +14,12 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" + +def strip_model_prefix(model: str) -> str: + """Strips the ``openrouter:`` vendor prefix from a model ID.""" + return model.removeprefix("openrouter:") + + _TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504} MAX_RETRIES = 2 @@ -49,7 +55,7 @@ async def call_openrouter( "HTTP-Referer": "https://github.com/ianreppel/crossfire", }, json={ - "model": model.removeprefix("openrouter:"), + "model": strip_model_prefix(model), "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, diff --git a/crossfire/core/pricing.py b/crossfire/core/pricing.py new file mode 100644 index 0000000..03f26a9 --- /dev/null +++ b/crossfire/core/pricing.py @@ -0,0 +1,229 @@ +"""Pricing cache and cost estimation for dry runs.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import httpx + +from crossfire.core.domain import CostEstimate, CrossfireConfiguration, ModelGroup, RunParameters +from crossfire.core.openrouter import strip_model_prefix +from crossfire.core.tokens import estimate_tokens + +OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" +PRICING_FILENAME = "pricing.json" + +# Default output tokens per call when the instruction has no explicit length signal. +# ~3,500 words: a substantial article, neither a tweet nor a novel. +_DEFAULT_GENERATOR_OUTPUT = 5000 +_DEFAULT_REVIEWER_OUTPUT = 2000 +_DEFAULT_SYNTHESIZER_OUTPUT = 5000 +_DEFAULT_ENRICHER_OUTPUT = 2000 + +_TOKENS_PER_WORD = 1.4 +_WORDS_PER_PAGE = 500 + +_WORD_COUNT_REGEX = re.compile(r"(\d[\d,]*)\s*[-\u2013]?\s*words?", re.IGNORECASE) +_PAGE_COUNT_REGEX = re.compile(r"(\d[\d,]*)\s*[-\u2013]?\s*pages?", re.IGNORECASE) + + +def _parse_pricing_entry(raw_pricing: Any) -> tuple[float, float]: + """Extracts ``(prompt_price, completion_price)`` per token from an OpenRouter pricing object. + + Handles both flat objects and tiered arrays (uses the first tier). + Returns ``(0.0, 0.0)`` when the pricing data is missing or unparseable. + """ + entry: Any = raw_pricing + if isinstance(entry, list): + entry = entry[0] if entry else {} + if not isinstance(entry, dict): + return 0.0, 0.0 + try: + prompt_price: float = float(entry.get("prompt", "0") or "0") + completion_price: float = float(entry.get("completion", "0") or "0") + except (ValueError, TypeError): + return 0.0, 0.0 + return prompt_price, completion_price + + +def parse_api_response(data: dict[str, Any]) -> dict[str, tuple[float, float]]: + """Parses the OpenRouter ``/api/v1/models`` response into a ``{model_id: (prompt, completion)}`` map.""" + models: dict[str, tuple[float, float]] = {} + for entry in data.get("data", []): + model_id: str = entry.get("id", "") + if not model_id: + continue + models[model_id] = _parse_pricing_entry(entry.get("pricing")) + return models + + +def fetch_pricing() -> dict[str, Any]: + """Fetches all model pricing from OpenRouter (synchronous).""" + with httpx.Client(timeout=30.0) as client: + response = client.get(OPENROUTER_MODELS_URL) + response.raise_for_status() + result: dict[str, Any] = response.json() + return result + + +def save_pricing(pricing: dict[str, tuple[float, float]], fetched_at: str, path: Path) -> None: + """Writes the pricing cache to *path* as JSON.""" + payload: dict[str, Any] = { + "fetched_at": fetched_at, + "models": { + model_id: {"prompt": prompt, "completion": completion} + for model_id, (prompt, completion) in sorted(pricing.items()) + }, + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def load_pricing(path: Path) -> tuple[dict[str, tuple[float, float]], str]: + """Loads the pricing from *path*. + + Returns ``(models, fetched_at)``. + Raises :class:`FileNotFoundError` if the file does not exist, + :class:`ValueError` if the JSON is malformed. + """ + raw: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) + fetched_at: str = raw.get("fetched_at", "") + models: dict[str, tuple[float, float]] = {} + for model_id, prices in raw.get("models", {}).items(): + if isinstance(prices, dict): + try: + models[model_id] = ( + float(prices.get("prompt", 0)), + float(prices.get("completion", 0)), + ) + except (ValueError, TypeError): + continue + return models, fetched_at + + +def _average_group_price( + group: ModelGroup, + pricing: dict[str, tuple[float, float]], + missing: list[str], +) -> tuple[float, float]: + """Computes the average per-token price across a model group. + + Returns ``(average_price_in, average_price_out)``. + Models missing from *pricing* are appended to *missing*. + """ + total_price_in: float = 0.0 + total_price_out: float = 0.0 + found: int = 0 + + for name in group.names: + api_id: str = strip_model_prefix(name) + if api_id not in pricing: + missing.append(name) + continue + prompt_price, completion_price = pricing[api_id] + total_price_in += prompt_price + total_price_out += completion_price + found += 1 + + if found == 0: + return 0.0, 0.0 + return total_price_in / found, total_price_out / found + + +def parse_length_hint(instruction: str) -> int | None: + """Extracts an output token estimate from explicit word or page counts in the *instruction*. + + Looks for patterns like "1,200 words" or "10 pages". This is a heuristic: + the number may refer to the input rather than the desired output (e.g. + "analyse this 200 page document"). When in doubt the estimate errs on the + high side, which is acceptable for an upper-bound cost estimate. + + Returns the estimated token count, or ``None`` when no length signal is found. + """ + match = _WORD_COUNT_REGEX.search(instruction) + if match: + words: int = int(match.group(1).replace(",", "")) + return int(words * _TOKENS_PER_WORD) + match = _PAGE_COUNT_REGEX.search(instruction) + if match: + pages: int = int(match.group(1).replace(",", "")) + return int(pages * _WORDS_PER_PAGE * _TOKENS_PER_WORD) + return None + + +def estimate_cost( + configuration: CrossfireConfiguration, + parameters: RunParameters, + pricing: dict[str, tuple[float, float]], + fetched_at: str, +) -> CostEstimate: + """Estimates the cost of the run described by *configuration* and *parameters*.""" + missing: list[str] = [] + instruction_tokens: int = estimate_tokens(parameters.task.instruction) + context_tokens: int = estimate_tokens(parameters.task.context) if parameters.task.context else 0 + + enricher_price_in, enricher_price_out = _average_group_price(configuration.enricher, pricing, missing) + generator_price_in, generator_price_out = _average_group_price(configuration.generators, pricing, missing) + reviewer_price_in, reviewer_price_out = _average_group_price(configuration.reviewers, pricing, missing) + synthesizer_price_in, synthesizer_price_out = _average_group_price(configuration.synthesizer, pricing, missing) + + hint: int | None = parse_length_hint(parameters.task.instruction) + generator_output: int = min(hint or _DEFAULT_GENERATOR_OUTPUT, configuration.generators.max_output_tokens) + reviewer_output: int = min(_DEFAULT_REVIEWER_OUTPUT, configuration.reviewers.max_output_tokens) + synthesizer_output: int = min(hint or _DEFAULT_SYNTHESIZER_OUTPUT, configuration.synthesizer.max_output_tokens) + enricher_output: int = min(_DEFAULT_ENRICHER_OUTPUT, configuration.enricher.max_output_tokens) + + num_generators: int = parameters.num_generators + num_reviewers: int = parameters.num_reviewers_per_candidate + num_rounds: int = parameters.num_rounds + + enrichment_active: bool = parameters.enrich and bool(configuration.enricher.names) + + # After enrichment the enriched instruction replaces the original in all + # downstream phases (generation, review, synthesis). + effective_instruction_tokens: int = enricher_output if enrichment_active else instruction_tokens + + total: float = 0.0 + + # -- enrichment: real input tokens -- + if enrichment_active: + enrichment_input: int = instruction_tokens + context_tokens + total += enrichment_input * enricher_price_in + enricher_output * enricher_price_out + + # -- generation: real input for round 1, estimated for rounds 2+ -- + generation_input_round_1: int = effective_instruction_tokens + context_tokens + generation_input_round_n: int = generation_input_round_1 + synthesizer_output + + total += num_generators * (generation_input_round_1 * generator_price_in + generator_output * generator_price_out) + if num_rounds > 1: + total += ( + num_generators + * (num_rounds - 1) + * (generation_input_round_n * generator_price_in + generator_output * generator_price_out) + ) + + # -- review: instruction (enriched if applicable) + estimated candidate output -- + reviewer_input: int = effective_instruction_tokens + generator_output + total += ( + num_generators + * num_reviewers + * num_rounds + * (reviewer_input * reviewer_price_in + reviewer_output * reviewer_price_out) + ) + + # -- synthesis: instruction (enriched if applicable) + estimated candidates and reviews -- + synthesis_input: int = ( + effective_instruction_tokens + + num_generators * generator_output + + num_generators * num_reviewers * reviewer_output + ) + total += num_rounds * (synthesis_input * synthesizer_price_in + synthesizer_output * synthesizer_price_out) + + unique_missing: tuple[str, ...] = tuple(dict.fromkeys(missing)) + return CostEstimate( + total_usd=total, + missing_models=unique_missing, + fetched_at=fetched_at, + ) diff --git a/crossfire/ui/tui.py b/crossfire/ui/tui.py index f0a323d..afe7661 100644 --- a/crossfire/ui/tui.py +++ b/crossfire/ui/tui.py @@ -19,7 +19,7 @@ from rich.table import Table from rich.text import Text -from crossfire.core.domain import Phase, RunParameters +from crossfire.core.domain import CostEstimate, Phase, RunParameters _PHASE_LABELS: dict[Phase, tuple[str, str]] = { Phase.ENRICHMENT: ("Enriching", "blue"), @@ -195,7 +195,12 @@ def on_run_end(self) -> None: def _remove_handler(self) -> None: logging.getLogger("crossfire").removeHandler(self._handler) - def finish(self, cost_summary: dict[str, Any]) -> None: + def finish( + self, + cost_summary: dict[str, Any], + *, + cost_estimate: CostEstimate | None = None, + ) -> None: """Stops the live display and prints the summary table.""" self._remove_handler() events = self._handler.events @@ -213,12 +218,18 @@ def finish(self, cost_summary: dict[str, Any]) -> None: failures = sum(1 for event in events if event.get("event") == "round_failed") table.add_row("Rounds completed", str(rounds_completed)) - table.add_row("Compressions applied", str(compressions)) - table.add_row("Models dropped", str(drops)) - table.add_row("Round failures", str(failures)) - table.add_row("Total input tokens", str(cost_summary.get("total_input_tokens", 0))) - table.add_row("Total output tokens", str(cost_summary.get("total_output_tokens", 0))) - table.add_row("Total cost", f"${cost_summary.get('total_cost', 0):.4f}") + + if cost_estimate is not None: + table.add_row("Estimated cost", f"${cost_estimate.total_usd:.2f}") + if cost_estimate.fetched_at: + table.add_row("Prices from", cost_estimate.fetched_at[:10]) + else: + table.add_row("Compressions applied", str(compressions)) + table.add_row("Models dropped", str(drops)) + table.add_row("Round failures", str(failures)) + table.add_row("Total input tokens", str(cost_summary.get("total_input_tokens", 0))) + table.add_row("Total output tokens", str(cost_summary.get("total_output_tokens", 0))) + table.add_row("Total cost", f"${cost_summary.get('total_cost', 0):.4f}") self.console.print(table) diff --git a/pricing.json b/pricing.json new file mode 100644 index 0000000..9b696d3 --- /dev/null +++ b/pricing.json @@ -0,0 +1,1377 @@ +{ + "fetched_at": "2026-04-21T14:47:33Z", + "models": { + "ai21/jamba-large-1.7": { + "prompt": 2e-06, + "completion": 8e-06 + }, + "aion-labs/aion-1.0": { + "prompt": 4e-06, + "completion": 8e-06 + }, + "aion-labs/aion-1.0-mini": { + "prompt": 7e-07, + "completion": 1.4e-06 + }, + "aion-labs/aion-2.0": { + "prompt": 8e-07, + "completion": 1.6e-06 + }, + "aion-labs/aion-rp-llama-3.1-8b": { + "prompt": 8e-07, + "completion": 1.6e-06 + }, + "alfredpros/codellama-7b-instruct-solidity": { + "prompt": 8e-07, + "completion": 1.2e-06 + }, + "alibaba/tongyi-deepresearch-30b-a3b": { + "prompt": 9e-08, + "completion": 4.5e-07 + }, + "allenai/olmo-3-32b-think": { + "prompt": 1.5e-07, + "completion": 5e-07 + }, + "allenai/olmo-3.1-32b-instruct": { + "prompt": 2e-07, + "completion": 6e-07 + }, + "alpindale/goliath-120b": { + "prompt": 3.75e-06, + "completion": 7.5e-06 + }, + "amazon/nova-2-lite-v1": { + "prompt": 3e-07, + "completion": 2.5e-06 + }, + "amazon/nova-lite-v1": { + "prompt": 6e-08, + "completion": 2.4e-07 + }, + "amazon/nova-micro-v1": { + "prompt": 3.5e-08, + "completion": 1.4e-07 + }, + "amazon/nova-premier-v1": { + "prompt": 2.5e-06, + "completion": 1.25e-05 + }, + "amazon/nova-pro-v1": { + "prompt": 8e-07, + "completion": 3.2e-06 + }, + "anthracite-org/magnum-v4-72b": { + "prompt": 3e-06, + "completion": 5e-06 + }, + "anthropic/claude-3-haiku": { + "prompt": 2.5e-07, + "completion": 1.25e-06 + }, + "anthropic/claude-3.5-haiku": { + "prompt": 8e-07, + "completion": 4e-06 + }, + "anthropic/claude-3.7-sonnet": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "anthropic/claude-3.7-sonnet:thinking": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "anthropic/claude-haiku-4.5": { + "prompt": 1e-06, + "completion": 5e-06 + }, + "anthropic/claude-opus-4": { + "prompt": 1.5e-05, + "completion": 7.5e-05 + }, + "anthropic/claude-opus-4.1": { + "prompt": 1.5e-05, + "completion": 7.5e-05 + }, + "anthropic/claude-opus-4.5": { + "prompt": 5e-06, + "completion": 2.5e-05 + }, + "anthropic/claude-opus-4.6": { + "prompt": 5e-06, + "completion": 2.5e-05 + }, + "anthropic/claude-opus-4.6-fast": { + "prompt": 3e-05, + "completion": 0.00015 + }, + "anthropic/claude-opus-4.7": { + "prompt": 5e-06, + "completion": 2.5e-05 + }, + "anthropic/claude-sonnet-4": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "anthropic/claude-sonnet-4.5": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "anthropic/claude-sonnet-4.6": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "arcee-ai/coder-large": { + "prompt": 5e-07, + "completion": 8e-07 + }, + "arcee-ai/maestro-reasoning": { + "prompt": 9e-07, + "completion": 3.3e-06 + }, + "arcee-ai/spotlight": { + "prompt": 1.8e-07, + "completion": 1.8e-07 + }, + "arcee-ai/trinity-large-preview:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "arcee-ai/trinity-large-thinking": { + "prompt": 2.2e-07, + "completion": 8.5e-07 + }, + "arcee-ai/trinity-mini": { + "prompt": 4.5e-08, + "completion": 1.5e-07 + }, + "arcee-ai/virtuoso-large": { + "prompt": 7.5e-07, + "completion": 1.2e-06 + }, + "baidu/ernie-4.5-21b-a3b": { + "prompt": 7e-08, + "completion": 2.8e-07 + }, + "baidu/ernie-4.5-21b-a3b-thinking": { + "prompt": 7e-08, + "completion": 2.8e-07 + }, + "baidu/ernie-4.5-300b-a47b": { + "prompt": 2.8e-07, + "completion": 1.1e-06 + }, + "baidu/ernie-4.5-vl-28b-a3b": { + "prompt": 1.4e-07, + "completion": 5.6e-07 + }, + "baidu/ernie-4.5-vl-424b-a47b": { + "prompt": 4.2e-07, + "completion": 1.25e-06 + }, + "bytedance-seed/seed-1.6": { + "prompt": 2.5e-07, + "completion": 2e-06 + }, + "bytedance-seed/seed-1.6-flash": { + "prompt": 7.5e-08, + "completion": 3e-07 + }, + "bytedance-seed/seed-2.0-lite": { + "prompt": 2.5e-07, + "completion": 2e-06 + }, + "bytedance-seed/seed-2.0-mini": { + "prompt": 1e-07, + "completion": 4e-07 + }, + "bytedance/ui-tars-1.5-7b": { + "prompt": 1e-07, + "completion": 2e-07 + }, + "cognitivecomputations/dolphin-mistral-24b-venice-edition:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "cohere/command-a": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "cohere/command-r-08-2024": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "cohere/command-r-plus-08-2024": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "cohere/command-r7b-12-2024": { + "prompt": 3.75e-08, + "completion": 1.5e-07 + }, + "deepcogito/cogito-v2.1-671b": { + "prompt": 1.25e-06, + "completion": 1.25e-06 + }, + "deepseek/deepseek-chat": { + "prompt": 3.2e-07, + "completion": 8.9e-07 + }, + "deepseek/deepseek-chat-v3-0324": { + "prompt": 2e-07, + "completion": 7.7e-07 + }, + "deepseek/deepseek-chat-v3.1": { + "prompt": 1.5e-07, + "completion": 7.5e-07 + }, + "deepseek/deepseek-r1": { + "prompt": 7e-07, + "completion": 2.5e-06 + }, + "deepseek/deepseek-r1-0528": { + "prompt": 5e-07, + "completion": 2.15e-06 + }, + "deepseek/deepseek-r1-distill-llama-70b": { + "prompt": 7e-07, + "completion": 8e-07 + }, + "deepseek/deepseek-r1-distill-qwen-32b": { + "prompt": 2.9e-07, + "completion": 2.9e-07 + }, + "deepseek/deepseek-v3.1-terminus": { + "prompt": 2.1e-07, + "completion": 7.9e-07 + }, + "deepseek/deepseek-v3.2": { + "prompt": 2.52e-07, + "completion": 3.78e-07 + }, + "deepseek/deepseek-v3.2-exp": { + "prompt": 2.7e-07, + "completion": 4.1e-07 + }, + "deepseek/deepseek-v3.2-speciale": { + "prompt": 4e-07, + "completion": 1.2e-06 + }, + "essentialai/rnj-1-instruct": { + "prompt": 1.5e-07, + "completion": 1.5e-07 + }, + "google/gemini-2.0-flash-001": { + "prompt": 1e-07, + "completion": 4e-07 + }, + "google/gemini-2.0-flash-lite-001": { + "prompt": 7.5e-08, + "completion": 3e-07 + }, + "google/gemini-2.5-flash": { + "prompt": 3e-07, + "completion": 2.5e-06 + }, + "google/gemini-2.5-flash-image": { + "prompt": 3e-07, + "completion": 2.5e-06 + }, + "google/gemini-2.5-flash-lite": { + "prompt": 1e-07, + "completion": 4e-07 + }, + "google/gemini-2.5-flash-lite-preview-09-2025": { + "prompt": 1e-07, + "completion": 4e-07 + }, + "google/gemini-2.5-pro": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "google/gemini-2.5-pro-preview": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "google/gemini-2.5-pro-preview-05-06": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "google/gemini-3-flash-preview": { + "prompt": 5e-07, + "completion": 3e-06 + }, + "google/gemini-3-pro-image-preview": { + "prompt": 2e-06, + "completion": 1.2e-05 + }, + "google/gemini-3.1-flash-image-preview": { + "prompt": 5e-07, + "completion": 3e-06 + }, + "google/gemini-3.1-flash-lite-preview": { + "prompt": 2.5e-07, + "completion": 1.5e-06 + }, + "google/gemini-3.1-pro-preview": { + "prompt": 2e-06, + "completion": 1.2e-05 + }, + "google/gemini-3.1-pro-preview-customtools": { + "prompt": 2e-06, + "completion": 1.2e-05 + }, + "google/gemma-2-27b-it": { + "prompt": 6.5e-07, + "completion": 6.5e-07 + }, + "google/gemma-3-12b-it": { + "prompt": 4e-08, + "completion": 1.3e-07 + }, + "google/gemma-3-12b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/gemma-3-27b-it": { + "prompt": 8e-08, + "completion": 1.6e-07 + }, + "google/gemma-3-27b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/gemma-3-4b-it": { + "prompt": 4e-08, + "completion": 8e-08 + }, + "google/gemma-3-4b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/gemma-3n-e2b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/gemma-3n-e4b-it": { + "prompt": 6e-08, + "completion": 1.2e-07 + }, + "google/gemma-3n-e4b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/gemma-4-26b-a4b-it": { + "prompt": 8e-08, + "completion": 3.5e-07 + }, + "google/gemma-4-26b-a4b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/gemma-4-31b-it": { + "prompt": 1.3e-07, + "completion": 3.8e-07 + }, + "google/gemma-4-31b-it:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/lyria-3-clip-preview": { + "prompt": 0.0, + "completion": 0.0 + }, + "google/lyria-3-pro-preview": { + "prompt": 0.0, + "completion": 0.0 + }, + "gryphe/mythomax-l2-13b": { + "prompt": 6e-08, + "completion": 6e-08 + }, + "ibm-granite/granite-4.0-h-micro": { + "prompt": 1.7e-08, + "completion": 1.1e-07 + }, + "inception/mercury-2": { + "prompt": 2.5e-07, + "completion": 7.5e-07 + }, + "inflection/inflection-3-pi": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "inflection/inflection-3-productivity": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "kwaipilot/kat-coder-pro-v2": { + "prompt": 3e-07, + "completion": 1.2e-06 + }, + "liquid/lfm-2-24b-a2b": { + "prompt": 3e-08, + "completion": 1.2e-07 + }, + "liquid/lfm-2.5-1.2b-instruct:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "liquid/lfm-2.5-1.2b-thinking:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "mancer/weaver": { + "prompt": 7.5e-07, + "completion": 1e-06 + }, + "meta-llama/llama-3-70b-instruct": { + "prompt": 5.1e-07, + "completion": 7.4e-07 + }, + "meta-llama/llama-3-8b-instruct": { + "prompt": 3e-08, + "completion": 4e-08 + }, + "meta-llama/llama-3.1-70b-instruct": { + "prompt": 4e-07, + "completion": 4e-07 + }, + "meta-llama/llama-3.1-8b-instruct": { + "prompt": 2e-08, + "completion": 5e-08 + }, + "meta-llama/llama-3.2-11b-vision-instruct": { + "prompt": 2.45e-07, + "completion": 2.45e-07 + }, + "meta-llama/llama-3.2-1b-instruct": { + "prompt": 2.7e-08, + "completion": 2e-07 + }, + "meta-llama/llama-3.2-3b-instruct": { + "prompt": 5.1e-08, + "completion": 3.4e-07 + }, + "meta-llama/llama-3.2-3b-instruct:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "meta-llama/llama-3.3-70b-instruct": { + "prompt": 1.2e-07, + "completion": 3.8e-07 + }, + "meta-llama/llama-3.3-70b-instruct:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "meta-llama/llama-4-maverick": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "meta-llama/llama-4-scout": { + "prompt": 8e-08, + "completion": 3e-07 + }, + "meta-llama/llama-guard-3-8b": { + "prompt": 4.8e-07, + "completion": 3e-08 + }, + "meta-llama/llama-guard-4-12b": { + "prompt": 1.8e-07, + "completion": 1.8e-07 + }, + "microsoft/phi-4": { + "prompt": 6.5e-08, + "completion": 1.4e-07 + }, + "microsoft/wizardlm-2-8x22b": { + "prompt": 6.2e-07, + "completion": 6.2e-07 + }, + "minimax/minimax-01": { + "prompt": 2e-07, + "completion": 1.1e-06 + }, + "minimax/minimax-m1": { + "prompt": 4e-07, + "completion": 2.2e-06 + }, + "minimax/minimax-m2": { + "prompt": 2.55e-07, + "completion": 1e-06 + }, + "minimax/minimax-m2-her": { + "prompt": 3e-07, + "completion": 1.2e-06 + }, + "minimax/minimax-m2.1": { + "prompt": 2.9e-07, + "completion": 9.5e-07 + }, + "minimax/minimax-m2.5": { + "prompt": 1.5e-07, + "completion": 1.2e-06 + }, + "minimax/minimax-m2.5:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "minimax/minimax-m2.7": { + "prompt": 3e-07, + "completion": 1.2e-06 + }, + "mistralai/codestral-2508": { + "prompt": 3e-07, + "completion": 9e-07 + }, + "mistralai/devstral-2512": { + "prompt": 4e-07, + "completion": 2e-06 + }, + "mistralai/devstral-medium": { + "prompt": 4e-07, + "completion": 2e-06 + }, + "mistralai/devstral-small": { + "prompt": 1e-07, + "completion": 3e-07 + }, + "mistralai/ministral-14b-2512": { + "prompt": 2e-07, + "completion": 2e-07 + }, + "mistralai/ministral-3b-2512": { + "prompt": 1e-07, + "completion": 1e-07 + }, + "mistralai/ministral-8b-2512": { + "prompt": 1.5e-07, + "completion": 1.5e-07 + }, + "mistralai/mistral-7b-instruct-v0.1": { + "prompt": 1.1e-07, + "completion": 1.9e-07 + }, + "mistralai/mistral-large": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "mistralai/mistral-large-2407": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "mistralai/mistral-large-2411": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "mistralai/mistral-large-2512": { + "prompt": 5e-07, + "completion": 1.5e-06 + }, + "mistralai/mistral-medium-3": { + "prompt": 4e-07, + "completion": 2e-06 + }, + "mistralai/mistral-medium-3.1": { + "prompt": 4e-07, + "completion": 2e-06 + }, + "mistralai/mistral-nemo": { + "prompt": 2e-08, + "completion": 4e-08 + }, + "mistralai/mistral-saba": { + "prompt": 2e-07, + "completion": 6e-07 + }, + "mistralai/mistral-small-24b-instruct-2501": { + "prompt": 5e-08, + "completion": 8e-08 + }, + "mistralai/mistral-small-2603": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "mistralai/mistral-small-3.1-24b-instruct": { + "prompt": 3.5e-07, + "completion": 5.6e-07 + }, + "mistralai/mistral-small-3.2-24b-instruct": { + "prompt": 7.5e-08, + "completion": 2e-07 + }, + "mistralai/mistral-small-creative": { + "prompt": 1e-07, + "completion": 3e-07 + }, + "mistralai/mixtral-8x22b-instruct": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "mistralai/mixtral-8x7b-instruct": { + "prompt": 5.4e-07, + "completion": 5.4e-07 + }, + "mistralai/pixtral-large-2411": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "mistralai/voxtral-small-24b-2507": { + "prompt": 1e-07, + "completion": 3e-07 + }, + "moonshotai/kimi-k2": { + "prompt": 5.7e-07, + "completion": 2.3e-06 + }, + "moonshotai/kimi-k2-0905": { + "prompt": 4e-07, + "completion": 2e-06 + }, + "moonshotai/kimi-k2-thinking": { + "prompt": 6e-07, + "completion": 2.5e-06 + }, + "moonshotai/kimi-k2.5": { + "prompt": 4.4e-07, + "completion": 2e-06 + }, + "moonshotai/kimi-k2.6": { + "prompt": 6e-07, + "completion": 2.8e-06 + }, + "morph/morph-v3-fast": { + "prompt": 8e-07, + "completion": 1.2e-06 + }, + "morph/morph-v3-large": { + "prompt": 9e-07, + "completion": 1.9e-06 + }, + "nex-agi/deepseek-v3.1-nex-n1": { + "prompt": 1.35e-07, + "completion": 5e-07 + }, + "nousresearch/hermes-2-pro-llama-3-8b": { + "prompt": 1.4e-07, + "completion": 1.4e-07 + }, + "nousresearch/hermes-3-llama-3.1-405b": { + "prompt": 1e-06, + "completion": 1e-06 + }, + "nousresearch/hermes-3-llama-3.1-405b:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "nousresearch/hermes-3-llama-3.1-70b": { + "prompt": 3e-07, + "completion": 3e-07 + }, + "nousresearch/hermes-4-405b": { + "prompt": 1e-06, + "completion": 3e-06 + }, + "nousresearch/hermes-4-70b": { + "prompt": 1.3e-07, + "completion": 4e-07 + }, + "nvidia/llama-3.1-nemotron-70b-instruct": { + "prompt": 1.2e-06, + "completion": 1.2e-06 + }, + "nvidia/llama-3.3-nemotron-super-49b-v1.5": { + "prompt": 1e-07, + "completion": 4e-07 + }, + "nvidia/nemotron-3-nano-30b-a3b": { + "prompt": 5e-08, + "completion": 2e-07 + }, + "nvidia/nemotron-3-nano-30b-a3b:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "nvidia/nemotron-3-super-120b-a12b": { + "prompt": 9e-08, + "completion": 4.5e-07 + }, + "nvidia/nemotron-3-super-120b-a12b:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "nvidia/nemotron-nano-12b-v2-vl": { + "prompt": 2e-07, + "completion": 6e-07 + }, + "nvidia/nemotron-nano-12b-v2-vl:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "nvidia/nemotron-nano-9b-v2": { + "prompt": 4e-08, + "completion": 1.6e-07 + }, + "nvidia/nemotron-nano-9b-v2:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "openai/gpt-3.5-turbo": { + "prompt": 5e-07, + "completion": 1.5e-06 + }, + "openai/gpt-3.5-turbo-0613": { + "prompt": 1e-06, + "completion": 2e-06 + }, + "openai/gpt-3.5-turbo-16k": { + "prompt": 3e-06, + "completion": 4e-06 + }, + "openai/gpt-3.5-turbo-instruct": { + "prompt": 1.5e-06, + "completion": 2e-06 + }, + "openai/gpt-4": { + "prompt": 3e-05, + "completion": 6e-05 + }, + "openai/gpt-4-0314": { + "prompt": 3e-05, + "completion": 6e-05 + }, + "openai/gpt-4-1106-preview": { + "prompt": 1e-05, + "completion": 3e-05 + }, + "openai/gpt-4-turbo": { + "prompt": 1e-05, + "completion": 3e-05 + }, + "openai/gpt-4-turbo-preview": { + "prompt": 1e-05, + "completion": 3e-05 + }, + "openai/gpt-4.1": { + "prompt": 2e-06, + "completion": 8e-06 + }, + "openai/gpt-4.1-mini": { + "prompt": 4e-07, + "completion": 1.6e-06 + }, + "openai/gpt-4.1-nano": { + "prompt": 1e-07, + "completion": 4e-07 + }, + "openai/gpt-4o": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "openai/gpt-4o-2024-05-13": { + "prompt": 5e-06, + "completion": 1.5e-05 + }, + "openai/gpt-4o-2024-08-06": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "openai/gpt-4o-2024-11-20": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "openai/gpt-4o-audio-preview": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "openai/gpt-4o-mini": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "openai/gpt-4o-mini-2024-07-18": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "openai/gpt-4o-mini-search-preview": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "openai/gpt-4o-search-preview": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "openai/gpt-5": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5-chat": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5-codex": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5-image": { + "prompt": 1e-05, + "completion": 1e-05 + }, + "openai/gpt-5-image-mini": { + "prompt": 2.5e-06, + "completion": 2e-06 + }, + "openai/gpt-5-mini": { + "prompt": 2.5e-07, + "completion": 2e-06 + }, + "openai/gpt-5-nano": { + "prompt": 5e-08, + "completion": 4e-07 + }, + "openai/gpt-5-pro": { + "prompt": 1.5e-05, + "completion": 0.00012 + }, + "openai/gpt-5.1": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5.1-chat": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5.1-codex": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5.1-codex-max": { + "prompt": 1.25e-06, + "completion": 1e-05 + }, + "openai/gpt-5.1-codex-mini": { + "prompt": 2.5e-07, + "completion": 2e-06 + }, + "openai/gpt-5.2": { + "prompt": 1.75e-06, + "completion": 1.4e-05 + }, + "openai/gpt-5.2-chat": { + "prompt": 1.75e-06, + "completion": 1.4e-05 + }, + "openai/gpt-5.2-codex": { + "prompt": 1.75e-06, + "completion": 1.4e-05 + }, + "openai/gpt-5.2-pro": { + "prompt": 2.1e-05, + "completion": 0.000168 + }, + "openai/gpt-5.3-chat": { + "prompt": 1.75e-06, + "completion": 1.4e-05 + }, + "openai/gpt-5.3-codex": { + "prompt": 1.75e-06, + "completion": 1.4e-05 + }, + "openai/gpt-5.4": { + "prompt": 2.5e-06, + "completion": 1.5e-05 + }, + "openai/gpt-5.4-mini": { + "prompt": 7.5e-07, + "completion": 4.5e-06 + }, + "openai/gpt-5.4-nano": { + "prompt": 2e-07, + "completion": 1.25e-06 + }, + "openai/gpt-5.4-pro": { + "prompt": 3e-05, + "completion": 0.00018 + }, + "openai/gpt-audio": { + "prompt": 2.5e-06, + "completion": 1e-05 + }, + "openai/gpt-audio-mini": { + "prompt": 6e-07, + "completion": 2.4e-06 + }, + "openai/gpt-oss-120b": { + "prompt": 3.9e-08, + "completion": 1.9e-07 + }, + "openai/gpt-oss-120b:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "openai/gpt-oss-20b": { + "prompt": 3e-08, + "completion": 1.4e-07 + }, + "openai/gpt-oss-20b:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "openai/gpt-oss-safeguard-20b": { + "prompt": 7.5e-08, + "completion": 3e-07 + }, + "openai/o1": { + "prompt": 1.5e-05, + "completion": 6e-05 + }, + "openai/o1-pro": { + "prompt": 0.00015, + "completion": 0.0006 + }, + "openai/o3": { + "prompt": 2e-06, + "completion": 8e-06 + }, + "openai/o3-deep-research": { + "prompt": 1e-05, + "completion": 4e-05 + }, + "openai/o3-mini": { + "prompt": 1.1e-06, + "completion": 4.4e-06 + }, + "openai/o3-mini-high": { + "prompt": 1.1e-06, + "completion": 4.4e-06 + }, + "openai/o3-pro": { + "prompt": 2e-05, + "completion": 8e-05 + }, + "openai/o4-mini": { + "prompt": 1.1e-06, + "completion": 4.4e-06 + }, + "openai/o4-mini-deep-research": { + "prompt": 2e-06, + "completion": 8e-06 + }, + "openai/o4-mini-high": { + "prompt": 1.1e-06, + "completion": 4.4e-06 + }, + "openrouter/auto": { + "prompt": -1.0, + "completion": -1.0 + }, + "openrouter/bodybuilder": { + "prompt": -1.0, + "completion": -1.0 + }, + "openrouter/elephant-alpha": { + "prompt": 0.0, + "completion": 0.0 + }, + "openrouter/free": { + "prompt": 0.0, + "completion": 0.0 + }, + "perplexity/sonar": { + "prompt": 1e-06, + "completion": 1e-06 + }, + "perplexity/sonar-deep-research": { + "prompt": 2e-06, + "completion": 8e-06 + }, + "perplexity/sonar-pro": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "perplexity/sonar-pro-search": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "perplexity/sonar-reasoning-pro": { + "prompt": 2e-06, + "completion": 8e-06 + }, + "prime-intellect/intellect-3": { + "prompt": 2e-07, + "completion": 1.1e-06 + }, + "qwen/qwen-2.5-72b-instruct": { + "prompt": 1.2e-07, + "completion": 3.9e-07 + }, + "qwen/qwen-2.5-7b-instruct": { + "prompt": 4e-08, + "completion": 1e-07 + }, + "qwen/qwen-2.5-coder-32b-instruct": { + "prompt": 6.6e-07, + "completion": 1e-06 + }, + "qwen/qwen-max": { + "prompt": 1.04e-06, + "completion": 4.16e-06 + }, + "qwen/qwen-plus": { + "prompt": 2.6e-07, + "completion": 7.8e-07 + }, + "qwen/qwen-plus-2025-07-28": { + "prompt": 2.6e-07, + "completion": 7.8e-07 + }, + "qwen/qwen-plus-2025-07-28:thinking": { + "prompt": 2.6e-07, + "completion": 7.8e-07 + }, + "qwen/qwen-turbo": { + "prompt": 3.25e-08, + "completion": 1.3e-07 + }, + "qwen/qwen-vl-max": { + "prompt": 5.2e-07, + "completion": 2.08e-06 + }, + "qwen/qwen-vl-plus": { + "prompt": 1.365e-07, + "completion": 4.095e-07 + }, + "qwen/qwen2.5-vl-72b-instruct": { + "prompt": 2.5e-07, + "completion": 7.5e-07 + }, + "qwen/qwen3-14b": { + "prompt": 6e-08, + "completion": 2.4e-07 + }, + "qwen/qwen3-235b-a22b": { + "prompt": 4.55e-07, + "completion": 1.82e-06 + }, + "qwen/qwen3-235b-a22b-2507": { + "prompt": 7.1e-08, + "completion": 1e-07 + }, + "qwen/qwen3-235b-a22b-thinking-2507": { + "prompt": 1.3e-07, + "completion": 6e-07 + }, + "qwen/qwen3-30b-a3b": { + "prompt": 8e-08, + "completion": 2.8e-07 + }, + "qwen/qwen3-30b-a3b-instruct-2507": { + "prompt": 9e-08, + "completion": 3e-07 + }, + "qwen/qwen3-30b-a3b-thinking-2507": { + "prompt": 8e-08, + "completion": 4e-07 + }, + "qwen/qwen3-32b": { + "prompt": 8e-08, + "completion": 2.4e-07 + }, + "qwen/qwen3-8b": { + "prompt": 5e-08, + "completion": 4e-07 + }, + "qwen/qwen3-coder": { + "prompt": 2.2e-07, + "completion": 1e-06 + }, + "qwen/qwen3-coder-30b-a3b-instruct": { + "prompt": 7e-08, + "completion": 2.7e-07 + }, + "qwen/qwen3-coder-flash": { + "prompt": 1.95e-07, + "completion": 9.75e-07 + }, + "qwen/qwen3-coder-next": { + "prompt": 1.5e-07, + "completion": 8e-07 + }, + "qwen/qwen3-coder-plus": { + "prompt": 6.5e-07, + "completion": 3.25e-06 + }, + "qwen/qwen3-coder:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "qwen/qwen3-max": { + "prompt": 7.8e-07, + "completion": 3.9e-06 + }, + "qwen/qwen3-max-thinking": { + "prompt": 7.8e-07, + "completion": 3.9e-06 + }, + "qwen/qwen3-next-80b-a3b-instruct": { + "prompt": 9e-08, + "completion": 1.1e-06 + }, + "qwen/qwen3-next-80b-a3b-instruct:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "qwen/qwen3-next-80b-a3b-thinking": { + "prompt": 9.75e-08, + "completion": 7.8e-07 + }, + "qwen/qwen3-vl-235b-a22b-instruct": { + "prompt": 2e-07, + "completion": 8.8e-07 + }, + "qwen/qwen3-vl-235b-a22b-thinking": { + "prompt": 2.6e-07, + "completion": 2.6e-06 + }, + "qwen/qwen3-vl-30b-a3b-instruct": { + "prompt": 1.3e-07, + "completion": 5.2e-07 + }, + "qwen/qwen3-vl-30b-a3b-thinking": { + "prompt": 1.3e-07, + "completion": 1.56e-06 + }, + "qwen/qwen3-vl-32b-instruct": { + "prompt": 1.04e-07, + "completion": 4.16e-07 + }, + "qwen/qwen3-vl-8b-instruct": { + "prompt": 8e-08, + "completion": 5e-07 + }, + "qwen/qwen3-vl-8b-thinking": { + "prompt": 1.17e-07, + "completion": 1.365e-06 + }, + "qwen/qwen3.5-122b-a10b": { + "prompt": 2.6e-07, + "completion": 2.08e-06 + }, + "qwen/qwen3.5-27b": { + "prompt": 1.95e-07, + "completion": 1.56e-06 + }, + "qwen/qwen3.5-35b-a3b": { + "prompt": 1.625e-07, + "completion": 1.3e-06 + }, + "qwen/qwen3.5-397b-a17b": { + "prompt": 3.9e-07, + "completion": 2.34e-06 + }, + "qwen/qwen3.5-9b": { + "prompt": 1e-07, + "completion": 1.5e-07 + }, + "qwen/qwen3.5-flash-02-23": { + "prompt": 6.5e-08, + "completion": 2.6e-07 + }, + "qwen/qwen3.5-plus-02-15": { + "prompt": 2.6e-07, + "completion": 1.56e-06 + }, + "qwen/qwen3.6-plus": { + "prompt": 3.25e-07, + "completion": 1.95e-06 + }, + "qwen/qwq-32b": { + "prompt": 1.5e-07, + "completion": 5.8e-07 + }, + "rekaai/reka-edge": { + "prompt": 1e-07, + "completion": 1e-07 + }, + "rekaai/reka-flash-3": { + "prompt": 1e-07, + "completion": 2e-07 + }, + "relace/relace-apply-3": { + "prompt": 8.5e-07, + "completion": 1.25e-06 + }, + "relace/relace-search": { + "prompt": 1e-06, + "completion": 3e-06 + }, + "sao10k/l3-euryale-70b": { + "prompt": 1.48e-06, + "completion": 1.48e-06 + }, + "sao10k/l3-lunaris-8b": { + "prompt": 4e-08, + "completion": 5e-08 + }, + "sao10k/l3.1-70b-hanami-x1": { + "prompt": 3e-06, + "completion": 3e-06 + }, + "sao10k/l3.1-euryale-70b": { + "prompt": 8.5e-07, + "completion": 8.5e-07 + }, + "sao10k/l3.3-euryale-70b": { + "prompt": 6.5e-07, + "completion": 7.5e-07 + }, + "stepfun/step-3.5-flash": { + "prompt": 1e-07, + "completion": 3e-07 + }, + "switchpoint/router": { + "prompt": 8.5e-07, + "completion": 3.4e-06 + }, + "tencent/hunyuan-a13b-instruct": { + "prompt": 1.4e-07, + "completion": 5.7e-07 + }, + "thedrummer/cydonia-24b-v4.1": { + "prompt": 3e-07, + "completion": 5e-07 + }, + "thedrummer/rocinante-12b": { + "prompt": 1.7e-07, + "completion": 4.3e-07 + }, + "thedrummer/skyfall-36b-v2": { + "prompt": 5.5e-07, + "completion": 8e-07 + }, + "thedrummer/unslopnemo-12b": { + "prompt": 4e-07, + "completion": 4e-07 + }, + "tngtech/deepseek-r1t2-chimera": { + "prompt": 3e-07, + "completion": 1.1e-06 + }, + "undi95/remm-slerp-l2-13b": { + "prompt": 4.5e-07, + "completion": 6.5e-07 + }, + "upstage/solar-pro-3": { + "prompt": 1.5e-07, + "completion": 6e-07 + }, + "writer/palmyra-x5": { + "prompt": 6e-07, + "completion": 6e-06 + }, + "x-ai/grok-3": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "x-ai/grok-3-beta": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "x-ai/grok-3-mini": { + "prompt": 3e-07, + "completion": 5e-07 + }, + "x-ai/grok-3-mini-beta": { + "prompt": 3e-07, + "completion": 5e-07 + }, + "x-ai/grok-4": { + "prompt": 3e-06, + "completion": 1.5e-05 + }, + "x-ai/grok-4-fast": { + "prompt": 2e-07, + "completion": 5e-07 + }, + "x-ai/grok-4.1-fast": { + "prompt": 2e-07, + "completion": 5e-07 + }, + "x-ai/grok-4.20": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "x-ai/grok-4.20-multi-agent": { + "prompt": 2e-06, + "completion": 6e-06 + }, + "x-ai/grok-code-fast-1": { + "prompt": 2e-07, + "completion": 1.5e-06 + }, + "xiaomi/mimo-v2-flash": { + "prompt": 9e-08, + "completion": 2.9e-07 + }, + "xiaomi/mimo-v2-omni": { + "prompt": 4e-07, + "completion": 2e-06 + }, + "xiaomi/mimo-v2-pro": { + "prompt": 1e-06, + "completion": 3e-06 + }, + "z-ai/glm-4-32b": { + "prompt": 1e-07, + "completion": 1e-07 + }, + "z-ai/glm-4.5": { + "prompt": 6e-07, + "completion": 2.2e-06 + }, + "z-ai/glm-4.5-air": { + "prompt": 1.3e-07, + "completion": 8.5e-07 + }, + "z-ai/glm-4.5-air:free": { + "prompt": 0.0, + "completion": 0.0 + }, + "z-ai/glm-4.5v": { + "prompt": 6e-07, + "completion": 1.8e-06 + }, + "z-ai/glm-4.6": { + "prompt": 3.9e-07, + "completion": 1.9e-06 + }, + "z-ai/glm-4.6v": { + "prompt": 3e-07, + "completion": 9e-07 + }, + "z-ai/glm-4.7": { + "prompt": 3.8e-07, + "completion": 1.74e-06 + }, + "z-ai/glm-4.7-flash": { + "prompt": 6e-08, + "completion": 4e-07 + }, + "z-ai/glm-5": { + "prompt": 6.5e-07, + "completion": 2.08e-06 + }, + "z-ai/glm-5-turbo": { + "prompt": 1.2e-06, + "completion": 4e-06 + }, + "z-ai/glm-5.1": { + "prompt": 6.98e-07, + "completion": 4.4e-06 + }, + "z-ai/glm-5v-turbo": { + "prompt": 1.2e-06, + "completion": 4e-06 + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 9462d1c..009ecea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "crossfire" -version = "0.1.0" +version = "0.2.0" description = "Multi-agent adversarial refinement orchestrator (i.e. Ralph Wiggum on 'roids)" maintainers = [{name = "Ian Reppel", email = "web@ianreppel.org"}] license = "MIT" diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..6b73d1f --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,417 @@ +"""Tests for pricing cache and cost estimation.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from crossfire.core.domain import ( + CrossfireConfiguration, + LimitsConfiguration, + Mode, + ModelGroup, + RunParameters, + SearchConfiguration, + Task, +) +from crossfire.core.pricing import ( + _average_group_price, + _parse_pricing_entry, + estimate_cost, + load_pricing, + parse_api_response, + parse_length_hint, + save_pricing, +) + + +class TestParsePricingEntry: + def test_flat_pricing(self): + prompt, completion = _parse_pricing_entry({"prompt": "0.000003", "completion": "0.000015"}) + assert prompt == pytest.approx(0.000003) + assert completion == pytest.approx(0.000015) + + def test_tiered_pricing_uses_first_tier(self): + tiered = [ + {"prompt": "0.000001", "completion": "0.000005"}, + {"prompt": "0.000002", "completion": "0.000010"}, + ] + prompt, completion = _parse_pricing_entry(tiered) + assert prompt == pytest.approx(0.000001) + assert completion == pytest.approx(0.000005) + + def test_free_model(self): + prompt, completion = _parse_pricing_entry({"prompt": "0", "completion": "0"}) + assert prompt == 0.0 + assert completion == 0.0 + + def test_missing_fields(self): + prompt, completion = _parse_pricing_entry({}) + assert prompt == 0.0 + assert completion == 0.0 + + def test_none_pricing(self): + prompt, completion = _parse_pricing_entry(None) + assert prompt == 0.0 + assert completion == 0.0 + + def test_empty_tiered_list(self): + prompt, completion = _parse_pricing_entry([]) + assert prompt == 0.0 + assert completion == 0.0 + + def test_null_string_values(self): + prompt, completion = _parse_pricing_entry({"prompt": None, "completion": None}) + assert prompt == 0.0 + assert completion == 0.0 + + +class TestParseApiResponse: + def test_parses_data_array(self): + raw = { + "data": [ + {"id": "vendor/model-a", "pricing": {"prompt": "0.000002", "completion": "0.000008"}}, + {"id": "vendor/model-b", "pricing": {"prompt": "0", "completion": "0"}}, + ] + } + result = parse_api_response(raw) + assert len(result) == 2 + assert result["vendor/model-a"] == pytest.approx((0.000002, 0.000008)) + assert result["vendor/model-b"] == (0.0, 0.0) + + def test_empty_data(self): + assert parse_api_response({"data": []}) == {} + + def test_missing_data_key(self): + assert parse_api_response({}) == {} + + def test_skips_entries_without_id(self): + raw = {"data": [{"pricing": {"prompt": "0.001", "completion": "0.002"}}]} + assert parse_api_response(raw) == {} + + +class TestPricingRoundTrip: + def test_save_and_load(self, tmp_path: Path): + pricing = { + "vendor/model-a": (0.000002, 0.000008), + "vendor/model-b": (0.0, 0.0), + } + fetched_at = "2026-04-20T14:30:00Z" + path = tmp_path / "pricing.json" + + save_pricing(pricing, fetched_at, path) + + loaded, loaded_at = load_pricing(path) + assert loaded_at == fetched_at + assert loaded["vendor/model-a"] == pytest.approx((0.000002, 0.000008)) + assert loaded["vendor/model-b"] == (0.0, 0.0) + + def test_load_missing_file(self, tmp_path: Path): + with pytest.raises(FileNotFoundError): + load_pricing(tmp_path / "nonexistent.json") + + def test_load_malformed_json(self, tmp_path: Path): + path = tmp_path / "pricing.json" + path.write_text("not json", encoding="utf-8") + with pytest.raises(ValueError): + load_pricing(path) + + def test_saved_file_is_valid_json(self, tmp_path: Path): + pricing = {"vendor/model-x": (0.001, 0.002)} + path = tmp_path / "pricing.json" + save_pricing(pricing, "2026-01-01T00:00:00Z", path) + parsed = json.loads(path.read_text(encoding="utf-8")) + assert "fetched_at" in parsed + assert "models" in parsed + assert parsed["models"]["vendor/model-x"]["prompt"] == 0.001 + + +class TestAverageGroupPrice: + def test_averages_prices_across_group(self): + group = ModelGroup( + names=("openrouter:vendor/cheap", "openrouter:vendor/expensive"), + context_window=16000, + max_output_tokens=4096, + ) + pricing = { + "vendor/cheap": (0.000001, 0.000010), + "vendor/expensive": (0.000005, 0.000002), + } + missing: list[str] = [] + price_in, price_out = _average_group_price(group, pricing, missing) + assert price_in == pytest.approx(0.000003) + assert price_out == pytest.approx(0.000006) + assert missing == [] + + def test_missing_model_tracked(self): + group = ModelGroup( + names=("openrouter:vendor/known", "openrouter:vendor/unknown"), + context_window=16000, + max_output_tokens=4096, + ) + pricing = {"vendor/known": (0.001, 0.002)} + missing: list[str] = [] + _average_group_price(group, pricing, missing) + assert missing == ["openrouter:vendor/unknown"] + + def test_all_missing_returns_zeros(self): + group = ModelGroup( + names=("openrouter:vendor/unknown",), + context_window=16000, + max_output_tokens=4096, + ) + missing: list[str] = [] + price_in, price_out = _average_group_price(group, {}, missing) + assert price_in == 0.0 + assert price_out == 0.0 + + +class TestParseLengthHint: + def test_word_count(self): + assert parse_length_hint("Write a 1,200 words essay on picking your nose in public") == int(1200 * 1.4) + + def test_word_count_hyphenated(self): + assert parse_length_hint("Write a 15,000-word paper on farting the national anthem") == int(15000 * 1.4) + + def test_word_range_uses_upper_bound(self): + result = parse_length_hint("Roughly 900\u20131,200 words") + assert result == int(1200 * 1.4) + + def test_page_count(self): + assert parse_length_hint("Write a 10 page report on the history of popping balloons") == int(10 * 500 * 1.4) + + def test_input_description_triggers_false_positive(self): + result = parse_length_hint("Analyse this 200 pages document") + assert result is not None + + def test_incidental_word_does_not_match(self): + assert parse_length_hint("Summarize the key words in this text") is None + + def test_no_hint(self): + assert parse_length_hint("Compare designs for underpants in space") is None + + def test_no_hint_on_empty(self): + assert parse_length_hint("") is None + + +@pytest.fixture() +def _estimation_configuration() -> CrossfireConfiguration: + return CrossfireConfiguration( + enricher=ModelGroup( + names=("openrouter:vendor/enricher",), + context_window=128000, + max_output_tokens=4096, + ), + generators=ModelGroup( + names=("openrouter:vendor/gen-a", "openrouter:vendor/gen-b"), + context_window=16000, + max_output_tokens=12000, + ), + reviewers=ModelGroup( + names=("openrouter:vendor/rev-a", "openrouter:vendor/rev-b", "openrouter:vendor/rev-c"), + context_window=16000, + max_output_tokens=8000, + ), + synthesizer=ModelGroup( + names=("openrouter:vendor/synth-a",), + context_window=200000, + max_output_tokens=32000, + ), + search=SearchConfiguration(enabled=False), + limits=LimitsConfiguration(), + ) + + +@pytest.fixture() +def _estimation_pricing() -> dict[str, tuple[float, float]]: + return { + "vendor/enricher": (0.0000004, 0.0000016), + "vendor/gen-a": (0.000003, 0.000015), + "vendor/gen-b": (0.0000003, 0.0000004), + "vendor/rev-a": (0.000001, 0.000005), + "vendor/rev-b": (0.0000004, 0.000002), + "vendor/rev-c": (0.0000003, 0.000001), + "vendor/synth-a": (0.000015, 0.000075), + } + + +class TestEstimateCost: + def test_positive_total( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test instruction"), + num_generators=2, + num_reviewers_per_candidate=1, + num_rounds=3, + dry_run=True, + enrich=True, + ) + estimate = estimate_cost(_estimation_configuration, parameters, _estimation_pricing, "2026-04-20T00:00:00Z") + assert estimate.total_usd > 0 + assert estimate.missing_models == () + assert estimate.fetched_at == "2026-04-20T00:00:00Z" + + def test_no_enrichment_uses_real_input( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters_with = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test instruction"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=True, + ) + parameters_without = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test instruction"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + estimate_with = estimate_cost(_estimation_configuration, parameters_with, _estimation_pricing, "") + estimate_without = estimate_cost(_estimation_configuration, parameters_without, _estimation_pricing, "") + assert estimate_with.total_usd > estimate_without.total_usd + + def test_more_rounds_costs_more( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters_1 = RunParameters( + mode=Mode.CODE, + task=Task(instruction="Build something"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + parameters_5 = RunParameters( + mode=Mode.CODE, + task=Task(instruction="Build something"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=5, + dry_run=True, + enrich=False, + ) + estimate_1 = estimate_cost(_estimation_configuration, parameters_1, _estimation_pricing, "") + estimate_5 = estimate_cost(_estimation_configuration, parameters_5, _estimation_pricing, "") + assert estimate_5.total_usd > estimate_1.total_usd + + def test_missing_models_flagged( + self, + _estimation_configuration: CrossfireConfiguration, + ): + partial_pricing = {"vendor/gen-a": (0.001, 0.002)} + parameters = RunParameters( + mode=Mode.EDIT, + task=Task(instruction="Edit something"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + estimate = estimate_cost(_estimation_configuration, parameters, partial_pricing, "") + assert len(estimate.missing_models) > 0 + + def test_zero_reviewers( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test instruction"), + num_generators=1, + num_reviewers_per_candidate=0, + num_rounds=1, + dry_run=True, + enrich=False, + ) + estimate = estimate_cost(_estimation_configuration, parameters, _estimation_pricing, "") + assert estimate.total_usd > 0 + + def test_large_context_increases_estimate( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters_no_context = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Summarize"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + parameters_with_context = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Summarize", context="x " * 20000), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + estimate_small = estimate_cost(_estimation_configuration, parameters_no_context, _estimation_pricing, "") + estimate_large = estimate_cost(_estimation_configuration, parameters_with_context, _estimation_pricing, "") + assert estimate_large.total_usd > estimate_small.total_usd + + def test_word_count_hint_increases_estimate( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters_default = RunParameters( + mode=Mode.WRITE, + task=Task(instruction="Write an essay"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + parameters_long = RunParameters( + mode=Mode.WRITE, + task=Task(instruction="Write a 10,000-word essay"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + enrich=False, + ) + estimate_default = estimate_cost(_estimation_configuration, parameters_default, _estimation_pricing, "") + estimate_long = estimate_cost(_estimation_configuration, parameters_long, _estimation_pricing, "") + assert estimate_long.total_usd > estimate_default.total_usd + + def test_estimate_is_frozen( + self, + _estimation_configuration: CrossfireConfiguration, + _estimation_pricing: dict[str, tuple[float, float]], + ): + parameters = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test"), + num_generators=1, + num_reviewers_per_candidate=1, + num_rounds=1, + dry_run=True, + ) + estimate = estimate_cost(_estimation_configuration, parameters, _estimation_pricing, "") + with pytest.raises(AttributeError): + estimate.total_usd = 0.0 # type: ignore[misc] diff --git a/uv.lock b/uv.lock index f4e5036..3554447 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" [[package]] @@ -213,7 +213,7 @@ wheels = [ [[package]] name = "crossfire" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -276,11 +276,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.0" +version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] @@ -322,11 +322,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.19" +version = "2.6.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, ] [[package]] @@ -491,11 +491,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.1" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -770,27 +770,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +version = "0.15.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] [[package]] @@ -866,7 +866,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.4" +version = "21.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -874,7 +874,7 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/c5/aff062c66b42e2183201a7ace10c6b2e959a9a16525c8e8ca8e59410d27a/virtualenv-21.2.1.tar.gz", hash = "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", size = 5844770, upload-time = "2026-04-09T18:47:11.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/20/0e/f083a76cb590e60dff3868779558eefefb8dfb7c9ed020babc7aa014ccbf/virtualenv-21.2.1-py3-none-any.whl", hash = "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2", size = 5828326, upload-time = "2026-04-09T18:47:09.331Z" }, ]