From 057b5dfe1bbf729acd4a39d4f92e71c667257ae3 Mon Sep 17 00:00:00 2001 From: rodricksz4h5 Date: Sat, 26 Sep 2026 00:31:13 +0530 Subject: [PATCH 1/2] Price tool-result images like top-level images in token estimates (#1895) --- pyproject.toml | 2 +- src/free_claude_code/core/anthropic/tokens.py | 36 ++++++--- tests/api/test_request_utils.py | 73 +++++++++++++++++++ uv.lock | 2 +- 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f550c2563..beb914df22 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.65" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" license = "AGPL-3.0-only" 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/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/uv.lock b/uv.lock index 0853fc127e..9183db8a53 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.64" +version = "6.2.65" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 4340ffc2384ffc6fa36f2803123b51bc5ddb6407 Mon Sep 17 00:00:00 2001 From: aiapienthusiast <327629069+aiapienthusiast@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:13:25 +0400 Subject: [PATCH 2/2] Add Cheaper Inference provider (#1899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Add Cheaper Inference as a cloud provider so users can access its models through FCC. Closes #1898. ## How Register Cheaper Inference on the shared OpenAI chat transport with the fixed `https://api.cheaperinference.com/v1` endpoint, an API key, and an optional outbound proxy. Discover streaming text models through the authenticated catalog using its documented filters, including their context and output limits. Add the provider card with its logo and website link, credential validation, a README entry, and the smoke configuration. Bump FCC from 6.2.65 to 6.2.66. Fix the browser account test to wait for its held request before consuming it. Add temporary thread-stack diagnostics and test durations to investigate Windows browser-test stalls.

RetriggerConfidence Score: 5/5

No new or outstanding findings block merging under the review status rules.

Summary

Adds Cheaper Inference provider support, including model discovery, credential validation, admin metadata, documentation, and tests. The follow-up filters the model catalog for streaming and changes the package version. Reviews (2) · Last reviewed commit: ["Filter Cheaper Inference catalog and ali..."](https://github.com/alishahryar1/free-claude-code/commit/c629cd17b61c699edeca9646bccaa052a2054f9d) --------- Co-authored-by: Alishahryar1 --- .github/workflows/tests.yml | 1 + README.md | 3 +- e2e/conftest.py | 2 + e2e/test_connected_accounts.py | 22 +- pyproject.toml | 2 +- smoke/lib/config.py | 1 + .../api/admin_static/providers/SOURCES.md | 1 + .../providers/cheaperinference.svg | 5 + .../config/admin/provider_manifest.py | 8 + .../config/provider_catalog.py | 13 + src/free_claude_code/config/settings.py | 8 + .../providers/credential_validation.py | 2 + .../providers/openai_chat/profiles.py | 18 ++ .../contracts/test_provider_catalog_order.py | 1 + tests/contracts/test_smoke_config.py | 53 ++++ tests/providers/test_cheaperinference.py | 273 ++++++++++++++++++ tests/providers/test_credential_validation.py | 6 + tests/providers/test_provider_runtime.py | 29 ++ uv.lock | 2 +- 19 files changed, 439 insertions(+), 11 deletions(-) create mode 100644 src/free_claude_code/api/admin_static/providers/cheaperinference.svg create mode 100644 tests/providers/test_cheaperinference.py 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 beb914df22..4e62a6cdb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.65" +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/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/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 9183db8a53..489a472d91 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.65" +version = "6.2.66" source = { editable = "." } dependencies = [ { name = "aiohttp" },