diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ca05888751..8ef74adc13 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -173,6 +173,7 @@ jobs: - name: Run Playwright run: >- uv run --locked pytest e2e -v --tb=short + -o faulthandler_timeout=10 --durations=20 --tracing=retain-on-failure --screenshot=only-on-failure --full-page-screenshot --output=test-results diff --git a/README.md b/README.md index 8125c4977d..cd41bed3ed 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ## What You Get -- **54 ToS-friendly providers. 1.3B+ free tokens every month.** Use free, paid, subscription, and local models from one searchable UI without putting your account at risk. FCC follows provider terms and removes integrations if they stop being allowed. +- **55 ToS-friendly providers. 1.3B+ free tokens every month.** Use free, paid, subscription, and local models from one searchable UI without putting your account at risk. FCC follows provider terms and removes integrations if they stop being allowed. - **10 coding agents. One model catalog.** Run [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://github.com/openai/codex), [Pi](https://github.com/earendil-works/pi), [OpenCode](https://github.com/anomalyco/opencode), [Cline](https://github.com/cline/cline), [Hermes](https://github.com/NousResearch/hermes-agent), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Grok Build](https://github.com/xai-org/grok-build), [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2/), or [Aider](https://aider.chat/) with your FCC models. - **Keep coding through provider outages.** After retries are exhausted, FCC automatically tries your next configured model without making you restart the turn. This works across every client. - **Up to 90% fewer terminal-output tokens.** Optional [RTK](https://github.com/rtk-ai/rtk) filters common command output, while five FCC optimizations handle quota probes, command-prefix detection, titles, suggestions, and filepaths without calling a provider. @@ -253,6 +253,7 @@ from more than one provider before succeeding. | [Scaleway](https://console.scaleway.com/iam/api-keys) | `SCW_SECRET_KEY` | `scaleway/deepseek/deepseek-v4-flash` | | [Lightning AI](https://lightning.ai/) | `LIGHTNING_API_KEY` | `lightning/lightning-ai/Qwen3.8-27B` | | [Experiential Labs](https://platform.experientiallabs.ai/) | `EXPLABS_API_KEY` | `experiential/union-alpha` | +| [Cheaper Inference](https://cheaperinference.com/signup) | `CHEAPER_INFERENCE_API_KEY` | `cheaperinference/gpt-5.4-mini` | | [Ollama Cloud](https://ollama.com/settings/keys) | `OLLAMA_API_KEY` | `ollama_cloud/qwen3-coder:480b` | | [LM Studio](https://lmstudio.ai/) | `LM_STUDIO_BASE_URL` | `lmstudio/` | | [llama.cpp](https://github.com/ggml-org/llama.cpp) | `LLAMACPP_BASE_URL` | `llamacpp/` | diff --git a/e2e/conftest.py b/e2e/conftest.py index ece94712c9..5d7bdff642 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -1,6 +1,7 @@ """Isolated browser-test composition for the local Admin UI.""" import asyncio +import faulthandler import socket import sys import threading @@ -348,6 +349,7 @@ def run_server() -> None: listener.close() clear_settings_cache() if thread.is_alive(): + faulthandler.dump_traceback(file=sys.stderr) pytest.fail("Admin browser-test server did not stop") diff --git a/e2e/test_connected_accounts.py b/e2e/test_connected_accounts.py index f141740033..a5fdcef1ea 100644 --- a/e2e/test_connected_accounts.py +++ b/e2e/test_connected_accounts.py @@ -1,5 +1,7 @@ """Rendered connected-account flows use provider-owned capabilities and identity.""" +import time + import pytest from playwright.sync_api import Dialog, Page, Route, expect @@ -55,6 +57,15 @@ def __init__(self, base_url: str) -> None: self.hold_login = False self.pending_login: list[Route] = [] + def take_pending_status(self, page: Page) -> Route: + deadline = time.monotonic() + 5 + while not self.pending_status: + if time.monotonic() >= deadline: + pytest.fail("Account status request did not reach the route handler") + # Pump Playwright events so the route handler can enqueue the request. + page.wait_for_timeout(10) + return self.pending_status.pop(0) + def config(self, route: Route) -> None: response = route.fetch() config = response.json() @@ -163,9 +174,6 @@ def _open(page: Page, admin_base_url: str) -> None: def test_account_modes_wait_for_status_and_recover_after_load_failure( page: Page, admin_base_url: str, accounts: _Accounts ) -> None: - page.expose_function( - "pendingAccountStatusCount", lambda: len(accounts.pending_status) - ) accounts.hold_status.add("github_copilot") page.goto(f"{admin_base_url}/admin") copilot = page.locator('[data-provider="github_copilot"]') @@ -173,9 +181,7 @@ def test_account_modes_wait_for_status_and_recover_after_load_failure( expect(copilot.get_by_role("button", name="Loading…", exact=True)).to_be_disabled() expect(copilot.get_by_role("button", name="Connect", exact=True)).to_have_count(0) expect(openai.get_by_role("button", name="Connect", exact=True)).to_be_enabled() - page.wait_for_function("async () => await window.pendingAccountStatusCount() === 1") - - accounts.pending_status.pop().fulfill( + accounts.take_pending_status(page).fulfill( status=503, json={"detail": "Account status unavailable."} ) expect(copilot.locator(".provider-meta")).to_have_text( @@ -184,9 +190,9 @@ def test_account_modes_wait_for_status_and_recover_after_load_failure( expect(copilot.get_by_role("button", name="Connect", exact=True)).to_have_count(0) copilot.get_by_role("button", name="Retry", exact=True).click() expect(copilot.get_by_role("button", name="Loading…", exact=True)).to_be_disabled() - page.wait_for_function("async () => await window.pendingAccountStatusCount() === 1") + retry = accounts.take_pending_status(page) accounts.hold_status.clear() - accounts.pending_status.pop().fulfill(json=accounts.statuses["github_copilot"]) + retry.fulfill(json=accounts.statuses["github_copilot"]) expect(copilot.get_by_role("button", name="Connect", exact=True)).to_be_enabled() expect(copilot.get_by_role("button", name="Use device code")).to_have_count(0) diff --git a/pyproject.toml b/pyproject.toml index 6f550c2563..4e62a6cdb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.64" +version = "6.2.66" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" license = "AGPL-3.0-only" diff --git a/smoke/lib/config.py b/smoke/lib/config.py index 448c154c8c..594d52f144 100644 --- a/smoke/lib/config.py +++ b/smoke/lib/config.py @@ -95,6 +95,7 @@ "llm7": "llm7/default", "lightning": "lightning/lightning-ai/Qwen3.8-27B", "experiential": "experiential/union-alpha", + "cheaperinference": "cheaperinference/gpt-5.4-mini", "agnes": "agnes/agnes-2.0-flash", "zenmux": "zenmux/deepseek/deepseek-v4-flash-free", "wandb": "wandb/openai/gpt-oss-20b", diff --git a/src/free_claude_code/api/admin_static/providers/SOURCES.md b/src/free_claude_code/api/admin_static/providers/SOURCES.md index 88e08b5206..e8fb62f07a 100644 --- a/src/free_claude_code/api/admin_static/providers/SOURCES.md +++ b/src/free_claude_code/api/admin_static/providers/SOURCES.md @@ -11,6 +11,7 @@ The LLaMA.cpp icon comes from the upstream llama.cpp repository; its MIT license | `azure-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/azure-color.svg | | `bedrock-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/bedrock-color.svg | | `cerebras-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/cerebras-color.svg | +| `cheaperinference.svg` | https://www.cheaperinference.com/icon.svg | | `chutes.png` | https://chutes.ai/favicon.png | | `cline.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/cline.svg | | `cloudflare-color.svg` | https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/cloudflare-color.svg | diff --git a/src/free_claude_code/api/admin_static/providers/cheaperinference.svg b/src/free_claude_code/api/admin_static/providers/cheaperinference.svg new file mode 100644 index 0000000000..f389b7a499 --- /dev/null +++ b/src/free_claude_code/api/admin_static/providers/cheaperinference.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/free_claude_code/config/admin/provider_manifest.py b/src/free_claude_code/config/admin/provider_manifest.py index f79999bd3a..404e926f50 100644 --- a/src/free_claude_code/config/admin/provider_manifest.py +++ b/src/free_claude_code/config/admin/provider_manifest.py @@ -297,6 +297,14 @@ class ProviderFieldOverride(TypedDict, total=False): "hex characters; mint one at platform.experientiallabs.ai/settings/api-keys." ), }, + "CHEAPER_INFERENCE_API_KEY": { + "label": "Cheaper Inference API Key", + "description": ( + "Cheaper Inference OpenAI-compatible gateway API key for " + "api.cheaperinference.com/v1. " + "Keys start with ci_live_; create one at cheaperinference.com/signup." + ), + }, } diff --git a/src/free_claude_code/config/provider_catalog.py b/src/free_claude_code/config/provider_catalog.py index 90f7aef94b..d49f31abd8 100644 --- a/src/free_claude_code/config/provider_catalog.py +++ b/src/free_claude_code/config/provider_catalog.py @@ -86,6 +86,8 @@ LIGHTNING_DEFAULT_BASE = "https://lightning.ai/api/v1" # Experiential Labs OpenAI-compatible Chat Completions gateway. EXPERIENTIAL_DEFAULT_BASE = "https://api.experientiallabs.ai/v1" +# Cheaper Inference OpenAI-compatible Chat Completions gateway. +CHEAPERINFERENCE_DEFAULT_BASE = "https://api.cheaperinference.com/v1" # Agnes AI OpenAI-compatible Chat Completions API. AGNES_DEFAULT_BASE = "https://apihub.agnes-ai.com/v1" # ZenMux OpenAI-compatible Chat Completions gateway. @@ -687,6 +689,17 @@ def configuration_attrs(self) -> tuple[str, ...]: default_base_url=EXPERIENTIAL_DEFAULT_BASE, proxy_attr="experiential_proxy", ), + "cheaperinference": ProviderDescriptor( + provider_id="cheaperinference", + display_name="Cheaper Inference", + website_url="https://cheaperinference.com/", + logo_filename="cheaperinference.svg", + credential_env="CHEAPER_INFERENCE_API_KEY", + credential_url="https://cheaperinference.com/signup", + credential_attr="cheaperinference_api_key", + default_base_url=CHEAPERINFERENCE_DEFAULT_BASE, + proxy_attr="cheaperinference_proxy", + ), "ollama_cloud": ProviderDescriptor( provider_id="ollama_cloud", display_name="Ollama Cloud", diff --git a/src/free_claude_code/config/settings.py b/src/free_claude_code/config/settings.py index 0434b02aec..a0bf711187 100644 --- a/src/free_claude_code/config/settings.py +++ b/src/free_claude_code/config/settings.py @@ -195,6 +195,11 @@ class Settings(BaseModel): default=None, validation_alias="EXPLABS_API_KEY" ) + # ==================== Cheaper Inference (OpenAI-compatible) ==================== + cheaperinference_api_key: OptionalNonEmptyString = Field( + default=None, validation_alias="CHEAPER_INFERENCE_API_KEY" + ) + # ==================== Fireworks AI Config ==================== fireworks_api_key: OptionalNonEmptyString = Field( default=None, validation_alias="FIREWORKS_API_KEY" @@ -498,6 +503,9 @@ class Settings(BaseModel): experiential_proxy: OptionalNonEmptyString = Field( default=None, validation_alias="EXPLABS_PROXY" ) + cheaperinference_proxy: OptionalNonEmptyString = Field( + default=None, validation_alias="CHEAPER_INFERENCE_PROXY" + ) fireworks_proxy: OptionalNonEmptyString = Field( default=None, validation_alias="FIREWORKS_PROXY" ) diff --git a/src/free_claude_code/core/anthropic/tokens.py b/src/free_claude_code/core/anthropic/tokens.py index 72ec344356..cdce8f361e 100644 --- a/src/free_claude_code/core/anthropic/tokens.py +++ b/src/free_claude_code/core/anthropic/tokens.py @@ -1,15 +1,30 @@ """Token estimation for Anthropic-compatible requests.""" import json +from typing import Any from loguru import logger from free_claude_code.core.token_estimation import estimate_text_tokens -from .content import get_block_attr +from .content import get_block_attr, get_block_type from .models import Message, SystemContent, Tool +def _image_tokens(block: Any) -> int: + """Estimate one image block from its base64 size, or a flat cost without data. + + Tool-result content is not validated, so ``data`` can be any JSON value there; + only a string has a meaningful size, anything else gets the flat cost. + """ + source = get_block_attr(block, "source") + if isinstance(source, dict): + data = source.get("data") or source.get("base64") or "" + if isinstance(data, str) and data: + return max(85, len(data) // 3000) + return 765 + + def get_token_count( messages: list[Message], system: str | list[SystemContent] | None = None, @@ -50,20 +65,21 @@ def get_token_count( total_tokens += estimate_text_tokens(str(block_id)) total_tokens += 15 elif b_type == "image": - source = get_block_attr(block, "source") - if isinstance(source, dict): - data = source.get("data") or source.get("base64") or "" - if data: - total_tokens += max(85, len(data) // 3000) - else: - total_tokens += 765 - else: - total_tokens += 765 + total_tokens += _image_tokens(block) elif b_type == "tool_result": content = get_block_attr(block, "content", "") tool_use_id = get_block_attr(block, "tool_use_id", "") if isinstance(content, str): total_tokens += estimate_text_tokens(content) + elif isinstance(content, list): + # Images returned by a tool (Read on an image, MCP screenshots) + # are priced like top-level images; serializing them would + # count their base64 payload as text. + images = [p for p in content if get_block_type(p) == "image"] + others = [p for p in content if get_block_type(p) != "image"] + total_tokens += sum(_image_tokens(image) for image in images) + if others or not images: + total_tokens += estimate_text_tokens(json.dumps(others)) else: total_tokens += estimate_text_tokens(json.dumps(content)) total_tokens += estimate_text_tokens(str(tool_use_id)) diff --git a/src/free_claude_code/providers/credential_validation.py b/src/free_claude_code/providers/credential_validation.py index ac9fe82336..01b489bc7a 100644 --- a/src/free_claude_code/providers/credential_validation.py +++ b/src/free_claude_code/providers/credential_validation.py @@ -156,6 +156,7 @@ class _Probe: # https://inference-docs.cerebras.ai/api-reference/models/list-models # https://docs.sambanova.ai/docs/api-reference/models/get-environments-available-model-list-metadata # https://docs.fireworks.ai/api-reference/list-accounts + # https://cheaperinference.com/docs _Probe("deepinfra", "https://api.deepinfra.com/v1/me", _identity("uid")), _Probe("mistral", "/models", _MODELS), _Probe("wandb", "/models", _MODELS), @@ -167,6 +168,7 @@ class _Probe: ), _Probe("cerebras", "/models", _MODELS), _Probe("sambanova", "/models", _MODELS), + _Probe("cheaperinference", "/models", _MODELS), _Probe( "fireworks", "https://api.fireworks.ai/v1/accounts?pageSize=1", diff --git a/src/free_claude_code/providers/openai_chat/profiles.py b/src/free_claude_code/providers/openai_chat/profiles.py index d6ad63dfa2..7ba9702fab 100644 --- a/src/free_claude_code/providers/openai_chat/profiles.py +++ b/src/free_claude_code/providers/openai_chat/profiles.py @@ -723,6 +723,24 @@ def _zai_profile(provider_name: str) -> OpenAIChatProfile: ), model_listing=OpenAIModelListing(path="/models"), ), + "cheaperinference": OpenAIChatProfile( + _policy( + "CHEAPERINFERENCE", + ReasoningReplayMode.REASONING_CONTENT, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + NamedEffortReasoning( + _LOW_MEDIUM_HIGH, + enabled_value="medium", + ), + model_listing=OpenAIModelListing( + path="/models", + query_params=(("type", "text"), ("streaming", "true")), + required_path_values=((("type",), ("text",)),), + context_window_tokens_path=("context_length",), + max_output_tokens_path=("max_output_tokens",), + ), + ), "ollama_cloud": OpenAIChatProfile( _policy( "OLLAMA_CLOUD", diff --git a/tests/api/test_request_utils.py b/tests/api/test_request_utils.py index 9312238e98..8c306565ec 100644 --- a/tests/api/test_request_utils.py +++ b/tests/api/test_request_utils.py @@ -1,5 +1,6 @@ """Tests for API request detection and token counting helpers.""" +import json from unittest.mock import MagicMock, patch import pytest @@ -549,6 +550,78 @@ def test_image_block_with_dict_source(self): count = get_token_count([msg]) assert count >= 85 + def test_image_in_tool_result_costs_the_same_as_a_top_level_image(self): + """A tool-returned image (Read on a PNG, an MCP screenshot) is priced as an image. + + Serializing it with the rest of the tool result counted its base64 payload as + text: a 300 KB screenshot came out near 215k tokens instead of about 100. + """ + image = { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "A" * 300_000, + }, + } + text = {"type": "text", "text": "Screenshot captured"} + + def user_message(*blocks): + msg = MagicMock() + msg.content = list(blocks) + return msg + + def tool_result(*parts): + return user_message( + {"type": "tool_result", "tool_use_id": "t1", "content": list(parts)} + ) + + def image_cost(wrap): + return get_token_count([wrap(text, image)]) - get_token_count([wrap(text)]) + + assert image_cost(tool_result) == image_cost(user_message) + assert image_cost(tool_result) < 1_000 + + @pytest.mark.parametrize("data", [12345, ["a"], {"x": 1}]) + def test_tool_result_image_with_non_string_data_gets_the_flat_cost(self, data): + """Tool-result content is not validated, so image data can be any JSON value. + + A number used to reach ``len()`` and turn count_tokens into an HTTP 500. + """ + image = {"type": "image", "source": {"type": "base64", "data": data}} + msg = MagicMock() + msg.content = [{"type": "tool_result", "tool_use_id": "t1", "content": [image]}] + no_data = MagicMock() + no_data.content = [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [{"type": "image", "source": {"type": "base64"}}], + } + ] + + assert get_token_count([msg]) == get_token_count([no_data]) + + @pytest.mark.parametrize( + "parts", + [ + [{"type": "text", "text": "line one"}, {"type": "text", "text": "two"}], + [], + ], + ) + def test_image_free_tool_result_list_is_counted_as_before(self, parts): + """Only images leave the serialized payload; image-free results are unchanged.""" + msg = MagicMock() + msg.content = [{"type": "tool_result", "tool_use_id": "t1", "content": parts}] + + with patch( + "free_claude_code.core.anthropic.tokens.estimate_text_tokens", + side_effect=len, + ): + count = get_token_count([msg]) + + assert count == len(json.dumps(parts)) + len("t1") + 8 + 4 + def test_known_payload_structural_overhead(self): """Protocol overhead remains separate from plain-text estimation.""" system_text = "You are a helpful assistant." diff --git a/tests/contracts/test_provider_catalog_order.py b/tests/contracts/test_provider_catalog_order.py index 485fbb6fe8..826dabed00 100644 --- a/tests/contracts/test_provider_catalog_order.py +++ b/tests/contracts/test_provider_catalog_order.py @@ -56,6 +56,7 @@ "scaleway", "lightning", "experiential", + "cheaperinference", "ollama_cloud", "lmstudio", "llamacpp", diff --git a/tests/contracts/test_smoke_config.py b/tests/contracts/test_smoke_config.py index 4a49fb76db..9457c910f2 100644 --- a/tests/contracts/test_smoke_config.py +++ b/tests/contracts/test_smoke_config.py @@ -70,6 +70,7 @@ def _settings(**overrides): "llm7_api_key": "", "lightning_api_key": "", "experiential_api_key": "", + "cheaperinference_api_key": "", "fireworks_api_key": "", "novita_api_key": "", "cloudflare_api_token": "", @@ -337,6 +338,58 @@ def test_experiential_is_not_enabled_without_explicit_credential( assert config.provider_smoke_models() == [] +def test_cheaperinference_provider_configuration_uses_default_model( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_CHEAPERINFERENCE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + cheaperinference_api_key="ci_live_key", + ) + ) + + assert config.has_provider_configuration("cheaperinference") + models = config.provider_smoke_models() + assert [model.provider for model in models] == ["cheaperinference"] + assert models[0].full_model == "cheaperinference/gpt-5.4-mini" + assert models[0].source == "provider_default" + + +def test_cheaperinference_smoke_override_accepts_model_with_or_without_prefix( + monkeypatch, +) -> None: + settings = _settings( + model="ollama/llama3.1", + ollama_base_url="", + cheaperinference_api_key="ci_live_key", + ) + for override in ( + "claude-sonnet-5", + "cheaperinference/claude-sonnet-5", + ): + monkeypatch.setenv("FCC_SMOKE_MODEL_CHEAPERINFERENCE", override) + models = _smoke_config(settings=settings).provider_smoke_models() + + assert [model.provider for model in models] == ["cheaperinference"] + assert models[0].full_model == "cheaperinference/claude-sonnet-5" + assert models[0].source == "FCC_SMOKE_MODEL_CHEAPERINFERENCE" + + +def test_cheaperinference_is_not_enabled_without_explicit_credential( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_CHEAPERINFERENCE", raising=False) + config = _smoke_config( + provider_matrix=frozenset({"cheaperinference"}), + settings=_settings(ollama_base_url="", cheaperinference_api_key=""), + ) + + assert not config.has_provider_configuration("cheaperinference") + assert config.provider_smoke_models() == [] + + def test_xai_provider_smoke_uses_current_grok_model(monkeypatch) -> None: monkeypatch.delenv("FCC_SMOKE_MODEL_XAI", raising=False) config = _smoke_config( diff --git a/tests/providers/test_cheaperinference.py b/tests/providers/test_cheaperinference.py new file mode 100644 index 0000000000..bf71d9d47e --- /dev/null +++ b/tests/providers/test_cheaperinference.py @@ -0,0 +1,273 @@ +"""Tests for the Cheaper Inference OpenAI-chat provider profile.""" + +from unittest.mock import AsyncMock + +import httpx2 +import pytest +from openai import AsyncOpenAI + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import CHEAPERINFERENCE_DEFAULT_BASE +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.json_types import JsonObject, JsonValue +from free_claude_code.core.reasoning import ReasoningEffort, ReasoningPolicy +from free_claude_code.providers.model_listing import ModelListResponseError +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from free_claude_code.providers.openai_chat.profiles import OPENAI_CHAT_PROFILES +from tests.providers.support import ( + immediate_admission, + make_provider_config, + profiled_provider, + reasoning_for, +) + +_MODEL = "gpt-5.4-mini" + + +@pytest.fixture +def cheaperinference_provider() -> OpenAIChatProvider: + return profiled_provider( + "cheaperinference", + make_provider_config( + api_key="test-cheaperinference-key", + base_url=CHEAPERINFERENCE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ), + admission=immediate_admission(provider_name="cheaperinference", max_attempts=1), + ) + + +def _request(**overrides: JsonValue) -> MessagesRequest: + payload: JsonObject = { + "model": _MODEL, + "messages": [{"role": "user", "content": "Inspect the file."}], + "tools": [ + { + "name": "read_file", + "description": "Read a file", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + } + ], + } + payload.update(overrides) + return MessagesRequest.model_validate(payload) + + +def test_constructs_standard_openai_chat_provider( + cheaperinference_provider: OpenAIChatProvider, +) -> None: + assert isinstance(cheaperinference_provider, OpenAIChatProvider) + assert cheaperinference_provider._provider_name == "CHEAPERINFERENCE" + assert cheaperinference_provider._api_key == "test-cheaperinference-key" + assert cheaperinference_provider._base_url == CHEAPERINFERENCE_DEFAULT_BASE + + +@pytest.mark.parametrize( + "reasoning", + [ReasoningPolicy.provider_default(), ReasoningPolicy.off()], +) +def test_default_and_off_reasoning_omit_effort_and_keep_standard_fields( + cheaperinference_provider: OpenAIChatProvider, + reasoning: ReasoningPolicy, +) -> None: + body = cheaperinference_provider._chat._build_request_body( + _request(), reasoning=reasoning + ) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + assert body["model"] == _MODEL + assert body["tools"][0]["function"]["name"] == "read_file" + assert "reasoning" not in body + assert "reasoning_effort" not in body + assert "thinking" not in body + assert "extra_body" not in body + + +@pytest.mark.parametrize( + ("reasoning", "expected"), + ( + (ReasoningPolicy.on(), "medium"), + (ReasoningPolicy.on(effort=ReasoningEffort.MINIMAL), "low"), + (ReasoningPolicy.on(effort=ReasoningEffort.LOW), "low"), + (ReasoningPolicy.on(effort=ReasoningEffort.MEDIUM), "medium"), + (ReasoningPolicy.on(effort=ReasoningEffort.HIGH), "high"), + (ReasoningPolicy.on(effort=ReasoningEffort.XHIGH), "high"), + (ReasoningPolicy.on(effort=ReasoningEffort.MAX), "high"), + ), +) +def test_reasoning_maps_efforts_onto_documented_vocabulary( + cheaperinference_provider: OpenAIChatProvider, + reasoning: ReasoningPolicy, + expected: str, +) -> None: + body = cheaperinference_provider._chat._build_request_body( + _request(), reasoning=reasoning + ) + + assert body["reasoning_effort"] == expected + + +def test_replays_reasoning_content_with_tool_history( + cheaperinference_provider: OpenAIChatProvider, +) -> None: + request = _request( + messages=[ + {"role": "user", "content": "Inspect the file."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Read it first."}, + {"type": "text", "text": "I will inspect it."}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "read_file", + "input": {"path": "example.py"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "print('hello')", + } + ], + }, + ] + ) + + body = cheaperinference_provider._chat._build_request_body( + request, + reasoning=reasoning_for(request), + ) + + assert body["messages"][1] == { + "role": "assistant", + "content": "I will inspect it.", + "reasoning_content": "Read it first.", + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "example.py"}', + }, + } + ], + } + assert body["messages"][2] == { + "role": "tool", + "tool_call_id": "toolu_1", + "content": "print('hello')", + } + + +@pytest.mark.asyncio +async def test_model_catalog_keeps_text_models_with_token_limits( + cheaperinference_provider: OpenAIChatProvider, +) -> None: + cheaperinference_provider._client.get = AsyncMock( + return_value={ + "data": [ + { + "id": _MODEL, + "type": "text", + "context_length": 400_000, + "max_output_tokens": 128_000, + }, + { + "id": "claude-sonnet-5", + "type": "text", + "context_length": 1_000_000, + "max_output_tokens": 64_000, + }, + {"id": "image-model", "type": "image"}, + {"id": "video-model", "type": "video"}, + ] + } + ) + + assert await cheaperinference_provider.list_model_infos() == frozenset( + { + ProviderModelInfo( + _MODEL, + context_window_tokens=400_000, + max_output_tokens=128_000, + ), + ProviderModelInfo( + "claude-sonnet-5", + context_window_tokens=1_000_000, + max_output_tokens=64_000, + ), + } + ) + + +@pytest.mark.asyncio +async def test_model_catalog_rejects_missing_id( + cheaperinference_provider: OpenAIChatProvider, +) -> None: + cheaperinference_provider._client.get = AsyncMock( + return_value={"data": [{"type": "text"}]} + ) + + with pytest.raises(ModelListResponseError, match="include id"): + await cheaperinference_provider.list_model_infos() + + +@pytest.mark.asyncio +async def test_model_catalog_rejects_empty_catalog( + cheaperinference_provider: OpenAIChatProvider, +) -> None: + cheaperinference_provider._client.get = AsyncMock(return_value={"data": []}) + + with pytest.raises(ModelListResponseError, match="did not include any model ids"): + await cheaperinference_provider.list_model_infos() + + +@pytest.mark.asyncio +async def test_model_catalog_requests_streaming_text_models_with_bearer_auth() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + models = [{"id": _MODEL, "type": "text"}] + if request.url.params.get("streaming") != "true": + models.append({"id": "gpt-5.5-pro", "type": "text"}) + if request.url.params.get("type") != "text": + models.append({"id": "image-model", "type": "image"}) + return httpx2.Response(200, json={"data": models}) + + async with AsyncOpenAI( + api_key="wire-cheaperinference-key", + base_url=CHEAPERINFERENCE_DEFAULT_BASE, + max_retries=0, + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + ) as client: + cheaperinference_provider = OpenAIChatProvider( + make_provider_config( + api_key="wire-cheaperinference-key", + base_url=CHEAPERINFERENCE_DEFAULT_BASE, + ), + profile=OPENAI_CHAT_PROFILES["cheaperinference"], + admission=immediate_admission(provider_name="cheaperinference"), + client=client, + ) + model_infos = await cheaperinference_provider.list_model_infos() + + assert model_infos == frozenset({ProviderModelInfo(_MODEL)}) + assert len(requests) == 1 + assert str(requests[0].url) == ( + "https://api.cheaperinference.com/v1/models?type=text&streaming=true" + ) + assert requests[0].headers["authorization"] == "Bearer wire-cheaperinference-key" diff --git a/tests/providers/test_credential_validation.py b/tests/providers/test_credential_validation.py index be92287d78..8db66e8dc7 100644 --- a/tests/providers/test_credential_validation.py +++ b/tests/providers/test_credential_validation.py @@ -79,6 +79,12 @@ ), ("cerebras", "https://api.cerebras.ai/v1/models", {"data": []}, None), ("sambanova", "https://api.sambanova.ai/v1/models", {"data": []}, None), + ( + "cheaperinference", + "https://api.cheaperinference.com/v1/models", + {"data": []}, + None, + ), ( "fireworks", "https://api.fireworks.ai/v1/accounts?pageSize=1", diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py index fb443fa4ff..78e525ae2e 100644 --- a/tests/providers/test_provider_runtime.py +++ b/tests/providers/test_provider_runtime.py @@ -14,6 +14,7 @@ from free_claude_code.config.provider_catalog import ( AGNES_DEFAULT_BASE, BEDROCK_DEFAULT_BASE, + CHEAPERINFERENCE_DEFAULT_BASE, CHUTES_DEFAULT_BASE, CLINE_DEFAULT_BASE, COHERE_DEFAULT_BASE, @@ -114,6 +115,7 @@ def _make_settings(**overrides): mock.llm7_api_key = "test_llm7_key" mock.lightning_api_key = "test_lightning_key" mock.experiential_api_key = "test_experiential_key" + mock.cheaperinference_api_key = "test_cheaperinference_key" mock.nvidia_nim_proxy = None mock.open_router_proxy = None mock.lmstudio_proxy = None @@ -162,6 +164,7 @@ def _make_settings(**overrides): mock.llm7_proxy = None mock.lightning_proxy = None mock.experiential_proxy = None + mock.cheaperinference_proxy = None mock.kilo_api_key = "test_kilo_key" mock.kilo_proxy = None mock.openai_proxy = None @@ -323,6 +326,31 @@ async def test_experiential_provider_config_uses_key_base_and_proxy() -> None: assert isinstance(provider, OpenAIChatProvider) +@pytest.mark.asyncio +async def test_cheaperinference_provider_config_uses_key_base_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["cheaperinference"] + settings = _make_settings( + cheaperinference_api_key="ci_live_token", + cheaperinference_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + with patch("free_claude_code.providers.openai_chat.client.AsyncOpenAI"): + provider = await create_provider("cheaperinference", settings) + + assert descriptor.display_name == "Cheaper Inference" + assert descriptor.credential_env == "CHEAPER_INFERENCE_API_KEY" + assert descriptor.credential_attr == "cheaperinference_api_key" + assert descriptor.credential_url == "https://cheaperinference.com/signup" + assert descriptor.default_base_url == CHEAPERINFERENCE_DEFAULT_BASE + assert descriptor.base_url_attr is None + assert descriptor.proxy_attr == "cheaperinference_proxy" + assert config.api_key == "ci_live_token" + assert config.base_url == "https://api.cheaperinference.com/v1" + assert config.proxy == "http://proxy.test:8080" + assert isinstance(provider, OpenAIChatProvider) + + @pytest.mark.asyncio async def test_xai_provider_config_uses_key_base_and_proxy() -> None: descriptor = PROVIDER_CATALOG["xai"] @@ -962,6 +990,7 @@ async def test_create_provider_instantiates_each_builtin(): "llm7": OpenAIChatProvider, "lightning": OpenAIChatProvider, "experiential": OpenAIChatProvider, + "cheaperinference": OpenAIChatProvider, "opencode_go": OpenCodeProvider, "vercel": OpenAIChatProvider, "bedrock": OpenAIChatProvider, diff --git a/uv.lock b/uv.lock index 0853fc127e..489a472d91 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.64" +version = "6.2.66" source = { editable = "." } dependencies = [ { name = "aiohttp" },