Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/<model-id>` |
| [llama.cpp](https://github.com/ggml-org/llama.cpp) | `LLAMACPP_BASE_URL` | `llamacpp/<model-id>` |
Expand Down
2 changes: 2 additions & 0 deletions e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Isolated browser-test composition for the local Admin UI."""

import asyncio
import faulthandler
import socket
import sys
import threading
Expand Down Expand Up @@ -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")


Expand Down
22 changes: 14 additions & 8 deletions e2e/test_connected_accounts.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -163,19 +174,14 @@ 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"]')
openai = page.locator('[data-provider="openai"]')
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(
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions smoke/lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/free_claude_code/api/admin_static/providers/SOURCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions src/free_claude_code/config/admin/provider_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
},
}


Expand Down
13 changes: 13 additions & 0 deletions src/free_claude_code/config/provider_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/free_claude_code/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
)
Expand Down
36 changes: 26 additions & 10 deletions src/free_claude_code/core/anthropic/tokens.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions src/free_claude_code/providers/credential_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/free_claude_code/providers/openai_chat/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions tests/api/test_request_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for API request detection and token counting helpers."""

import json
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions tests/contracts/test_provider_catalog_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"scaleway",
"lightning",
"experiential",
"cheaperinference",
"ollama_cloud",
"lmstudio",
"llamacpp",
Expand Down
Loading
Loading