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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@

## What You Get

- **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.
- **56 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.
- **11 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/), [Aider](https://aider.chat/), or [VS Code Chat](https://code.visualstudio.com/) 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.
- **Native Code sessions in your browser.** Choose a folder and run Codex in the browser with real-time and background support. Freely switch providers/models in the same session. Support for switching harnesses in the same session coming soon!
Expand Down Expand Up @@ -256,6 +256,7 @@ For another API endpoint, open **Providers → Custom providers → Add provider
| [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` |
| [OrcaRouter](https://www.orcarouter.ai/) | `ORCAROUTER_API_KEY` | `orcarouter/deepseek/deepseek-v4-flash-free` |
| [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 Expand Up @@ -359,6 +360,7 @@ For editor and app integrations, install the client, start FCC, then open
**Admin UI → Integrations** and click **Connect** on its card.

- **Claude Code in VS Code**: Install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code).
- **VS Code Chat**: Install the latest [VS Code](https://code.visualstudio.com/), connect, and select an FCC model in its built-in Chat model picker.
- **Claude Desktop**: Install [Claude Desktop](https://claude.ai/download). Fully quit it before connecting or disconnecting, then reopen it. Disconnect returns to normal Claude sign-in.
- **Codex in VS Code and App**: Install the [Codex extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt) or Codex App.
- **Claude Code in JetBrains ACP**: Install Claude Agent in JetBrains AI Assistant and start it once, then click **Connect** in FCC. Reopen the IDE, select **Claude Code (FCC)**, and start a new chat. After JetBrains updates the agent, restart FCC before starting a new chat. Requires a local IDE in its standard installation locations.
Expand Down
13 changes: 8 additions & 5 deletions e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Isolated browser-test composition for the local Admin UI."""

import asyncio
import faulthandler
import socket
import sys
import threading
Expand All @@ -14,6 +13,7 @@
from playwright.sync_api import Page

from e2e.code_support import CodeControl
from e2e.server_shutdown import join_server
from free_claude_code.api.app import create_app
from free_claude_code.api.ports import ApiServices
from free_claude_code.application.model_metadata import ProviderModelInfo
Expand All @@ -33,6 +33,7 @@
claude_integration,
codex_integration,
jetbrains_acp_integration,
vscode_chat_integration,
)
from free_claude_code.providers.base import BaseProvider, ProviderConfig
from free_claude_code.providers.runtime import ProviderRuntime
Expand Down Expand Up @@ -185,6 +186,11 @@ def admin_base_url(
monkeypatch.setattr(
codex_integration, "config_path", lambda: tmp_path / ".codex" / "config.toml"
)
monkeypatch.setattr(
vscode_chat_integration,
"config_path",
lambda: tmp_path / "vscode/chatLanguageModels.json",
)
monkeypatch.setattr(
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
)
Expand Down Expand Up @@ -346,12 +352,9 @@ def run_server() -> None:
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
thread.join(timeout=5.0)
join_server(thread, code_control, request)
listener.close()
clear_settings_cache()
if thread.is_alive():
faulthandler.dump_traceback(file=sys.stderr)
pytest.fail("Admin browser-test server did not stop")


@pytest.fixture(autouse=True)
Expand Down
90 changes: 90 additions & 0 deletions e2e/server_shutdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Bounded cleanup for the browser fixture's server thread."""

import asyncio
import faulthandler
import inspect
import sys
import threading
import time
from concurrent.futures import Future

import pytest

from e2e.code_support import CodeControl


def _task_snapshot(control: CodeControl) -> str:
jobs = control.service._jobs
lines = [f"Code jobs: {sum(not job.done() for job in jobs)}"]
for task in asyncio.all_tasks():
lines.append(f"Task {task.get_name()} (Code job: {task in jobs}):")
current = task.get_coro()
while inspect.iscoroutine(current) or inspect.isgenerator(current):
frame = (
current.cr_frame if inspect.iscoroutine(current) else current.gi_frame
)
if frame is not None:
lines.append(
f" {frame.f_code.co_filename}:{frame.f_lineno} in {frame.f_code.co_name}"
)
current = (
current.cr_await
if inspect.iscoroutine(current)
else current.gi_yieldfrom
)
return "\n".join(lines)


def join_server(
thread: threading.Thread,
control: CodeControl,
request: pytest.FixtureRequest,
*,
diagnostic_after: float = 5,
timeout: float = 30,
) -> None:
# Native interruption can itself take five seconds before durable cleanup.
# Diagnose at that point, but give the entire fixture shutdown its own budget.
started = time.monotonic()
deadline = started + timeout
thread.join(min(diagnostic_after, timeout))
if not thread.is_alive():
return

print(
f"Server shutdown still pending after {time.monotonic() - started:.2f}s: "
f"{request.node.nodeid}",
file=sys.stderr,
)
faulthandler.dump_traceback(file=sys.stderr)
snapshot: Future[str] = Future()

def capture() -> None:
try:
snapshot.set_result(_task_snapshot(control))
except Exception as error:
snapshot.set_result(f"Server task snapshot failed: {type(error).__name__}")

if control.loop is None:
snapshot.set_result("Server loop unavailable for task snapshot")
else:
try:
control.loop.call_soon_threadsafe(capture)
except RuntimeError:
snapshot.set_result("Server loop closed before task snapshot")

thread.join(max(0, deadline - time.monotonic()))
print(
snapshot.result()
if snapshot.done()
else "Server loop did not provide a task snapshot",
file=sys.stderr,
)
if thread.is_alive():
reason = (
f"Admin browser-test server did not stop within {timeout:g}s: "
f"{request.node.nodeid}"
)
request.session.shouldstop = reason
faulthandler.dump_traceback(file=sys.stderr)
pytest.fail(reason)
23 changes: 13 additions & 10 deletions e2e/test_admin_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,14 @@ def test_connection_check_uses_disabled_loading_button(

def hold_check(route):
pending.append(route)
page.evaluate("window.integrationCheckIntercepted = true")
page.evaluate("window.integrationChecks = (window.integrationChecks || 0) + 1")

page.route(f"**/admin/api/integrations/{integration}", hold_check)
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator(f"#{button_id}")
for visit in range(2):
if visit:
page.get_by_role("button", name="Providers", exact=True).click()
page.evaluate("window.integrationCheckIntercepted = false")
page.get_by_role("button", name="Integrations", exact=True).click()
expect(button).to_be_disabled()
expect(button).to_have_text("Loading…")
Expand All @@ -46,9 +45,17 @@ def hold_check(route):
== "integration-spinner"
)
expect(page.locator("#view-integrations .status-pill")).to_have_count(0)
page.wait_for_function("window.integrationCheckIntercepted === true")
page.wait_for_function(
"count => window.integrationChecks >= count", arg=visit + 1
)
assert len(pending) == 1
pending.pop().fulfill(json={"connected": connected, "paths": None})
pending.pop().fulfill(
json={
"connected": connected,
"paths": None,
"update": {"state": "ready", "changed": False, "message": None},
}
)
expect(button).to_be_enabled()
expect(button).to_have_text("Disconnect" if connected else "Connect")
expect(button).to_have_attribute("aria-busy", "false")
Expand All @@ -63,13 +70,10 @@ def test_codex_connect_disconnect_and_modal_paths(
expect(page.locator("#openClaudeIntegration")).to_be_enabled()
expect(page.locator("#messageArea")).to_have_text("")
cards = page.locator("#view-integrations > article")
expect(cards).to_have_count(4)
expect(cards.first).to_be_visible()
expect(page.locator("#claudeIntegrationStatus")).to_have_count(0)
expect(page.locator("#openCodexIntegration")).to_be_enabled()
expect(page.locator("#codexIntegrationStatus")).to_have_count(0)
expect(cards.nth(1)).to_contain_text(
"Use FCC's models in the Codex VS Code extension and desktop app."
)
bounds = [card.bounding_box() for card in cards.all()]
if width >= 1200:
descriptions = [
Expand Down Expand Up @@ -124,8 +128,7 @@ def test_codex_connect_disconnect_and_modal_paths(
expect(opener).to_have_css("color", "rgb(239, 68, 68)")
if width >= 1200:
buttons = [
button.bounding_box()
for button in page.locator(".integration-card > button").all()
card.get_by_role("button").first.bounding_box() for card in cards.all()
]
for _, row in groupby(range(len(bounds)), key=lambda index: bounds[index]["y"]):
assert len({buttons[index]["y"] for index in row}) == 1
Expand Down
15 changes: 8 additions & 7 deletions e2e/test_admin_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,19 @@ def hold_first_status(route: Route) -> None:
route.continue_()
else:
startup.append(route)
page.evaluate("window.startupRequests = (window.startupRequests || 0) + 1")

def hold_manual(route: Route) -> None:
manual.append(route)
page.evaluate("window.manualRequests = (window.manualRequests || 0) + 1")

page.route("**/admin/api/status", hold_first_status)
page.route(
"**/admin/api/providers/open_router/test", lambda route: manual.append(route)
)
page.expose_function("startupRequestIntercepted", lambda: bool(startup))
page.expose_function("manualRequestIntercepted", lambda: bool(manual))
page.route("**/admin/api/providers/open_router/test", hold_manual)
page.evaluate("void refreshStartup()")
page.wait_for_function("window.startupRequestIntercepted()")
page.wait_for_function("window.startupRequests >= 1")
dialog = open_provider(page, "open_router")
dialog.get_by_role("button", name="Refresh models", exact=True).click()
page.wait_for_function("window.manualRequestIntercepted()")
page.wait_for_function("window.manualRequests >= 1")
expected = "Checking..."
if manual_result != "pending":
manual.pop().fulfill(
Expand Down
92 changes: 92 additions & 0 deletions e2e/test_admin_vscode_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import json

import pytest
from playwright.sync_api import expect

from free_claude_code.harnesses import vscode_chat_integration as vscode


def test_connect_and_disconnect_native_chat(page, admin_base_url):
path = vscode.config_path()
path.parent.mkdir(parents=True, exist_ok=True)
other = {"vendor": "customendpoint", "name": "Other", "models": []}
path.write_text(json.dumps([other]))
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator("#openVSCodeChatIntegration")
dialog = page.locator("#vscodeChatIntegrationDialog")
expect(button).to_have_text("Connect")
button.click()
expect(dialog).to_be_visible()
expect(dialog.locator("code")).to_have_text(str(path.resolve()))
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
button.click()
dialog.get_by_role("button", name="Close", exact=True).click()
expect(dialog).not_to_be_visible()
button.click()
dialog.get_by_role("button", name="Connect", exact=True).click()
expect(button).to_have_text("Disconnect")
expect(button).to_have_class("danger-button")
group = json.loads(path.read_text())[1]
assert group["apiType"] == "messages"
assert group["models"]
assert all(m["url"].endswith("/v1/messages") for m in group["models"])
page.reload()
expect(button).to_have_text("Disconnect")
expect(page.locator("#vscodeChatIntegrationMessage")).not_to_be_visible()
button.click()
dialog.get_by_role("button", name="Disconnect", exact=True).click()
expect(button).to_have_text("Connect")
assert json.loads(path.read_text()) == [other]


@pytest.mark.parametrize("status_read_failed", [False, True])
def test_failed_update_has_actionable_retry(page, admin_base_url, status_read_failed):
progress: dict[str, str | bool | None] = {
"state": "failed",
"changed": False,
"message": "Could not update settings.",
}
retries = []

def startup(route):
response = route.fetch()
payload = response.json()
payload["startup"]["integrations"]["vscode-chat"] = dict(progress)
route.fulfill(response=response, json=payload)

def status(route):
if status_read_failed and progress["state"] == "failed":
route.fulfill(status=503, json={"detail": "Could not read settings."})
else:
route.fulfill(
json={"connected": True, "paths": None, "update": dict(progress)}
)

def retry(route):
retries.append(route.request.method)
progress.update(state="ready", message=None)
route.fulfill(json={"update": dict(progress)})

try:
page.route("**/admin/api/status", startup)
page.route("**/admin/api/integrations/vscode-chat", status)
page.route("**/admin/api/integrations/vscode-chat/refresh", retry)
page.goto(f"{admin_base_url}/admin/integrations")
main = page.locator("#openVSCodeChatIntegration")
secondary = page.locator("#retryVSCodeChatIntegration")
if status_read_failed:
expect(main).to_have_text("Retry")
action = main
else:
expect(main).to_have_text("Disconnect")
expect(secondary).to_be_visible()
action = secondary
with page.expect_response("**/admin/api/integrations/vscode-chat/refresh"):
action.click()
assert retries == ["POST"]
expect(main).to_have_text("Disconnect")
expect(secondary).to_be_hidden()
expect(page.locator("#vscodeChatIntegrationMessage")).to_be_hidden()
finally:
page.unroute_all(behavior="wait")
Loading
Loading