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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
src/free_claude_code/core/data/cl100k_base.tiktoken -text linguist-vendored=true
scripts/update/fcc-update text eol=lf
scripts/update/*.cmd text eol=crlf
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Free-tier availability and limits are controlled by each provider and may change

<a id="install"></a>

### 1. Install Or Update
### 1. Install

macOS/Linux:

Expand All @@ -64,7 +64,7 @@ Windows PowerShell:
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1")))
```

Re-run the same command to update. When prompted, choose at least one coding agent and optionally RTK. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1).
When prompted, choose at least one coding agent and optionally RTK. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1).

### 2. Start FCC

Expand Down Expand Up @@ -483,7 +483,15 @@ Run `fcc-server --version` to check the installed version without starting FCC.

### Update

Re-run the matching command from [Install Or Update](#install).
Stop all running FCC commands, then run:

```sh
fcc-update
```

This runs the same installer as above, including its coding-agent prompts and checks. If you use voice support, pass the same voice options used during installation.

If your installation does not have `fcc-update` yet, run the [installer](#install) once to add it.

### Muse Code on native Windows

Expand Down
6 changes: 5 additions & 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.54"
version = "6.2.57"
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
readme = "README.md"
requires-python = ">=3.14.0"
Expand Down Expand Up @@ -66,6 +66,10 @@ voice_local = [
[tool.hatch.build.targets.wheel]
packages = ["src/free_claude_code"]

[tool.hatch.build.targets.wheel.shared-scripts]
"scripts/update/fcc-update" = "fcc-update"
"scripts/update/fcc-update.cmd" = "fcc-update.cmd"

[tool.uv]
required-version = ">=0.12.13"

Expand Down
3 changes: 2 additions & 1 deletion scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ $FccCommands = @(
"fcc-grok",
"fcc-muse",
"fcc-aider",
"fcc-update",
"fcc-init",
"free-claude-code"
)
Expand Down Expand Up @@ -1506,7 +1507,7 @@ function Configure-AndConfirmFreeClaudeCode {
[IO.Path]::AltDirectorySeparatorChar
)
$installedCommands = @{}
foreach ($commandName in @("fcc-desktop", "fcc-server", "fcc-claude", "fcc-codex", "fcc-pi", "fcc-opencode", "fcc-cline", "fcc-hermes", "fcc-dsh", "fcc-grok", "fcc-muse", "fcc-aider")) {
foreach ($commandName in @("fcc-desktop", "fcc-server", "fcc-claude", "fcc-codex", "fcc-pi", "fcc-opencode", "fcc-cline", "fcc-hermes", "fcc-dsh", "fcc-grok", "fcc-muse", "fcc-aider", "fcc-update.cmd")) {
$command = Get-ApplicationCommand $commandName
if (-not $command) {
throw "Free Claude Code installation did not create '$commandName'."
Expand Down
4 changes: 2 additions & 2 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ UV_INSTALL_URL="https://astral.sh/uv/install.sh"
FCC_MACOS_BUNDLE_ID="io.github.alishahryar1.free-claude-code"
FCC_MACOS_OWNER_FILE=".free-claude-code-owner"
# Include retired entry points so updates reject older FCC processes before replacement.
FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-init free-claude-code"
FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update fcc-init free-claude-code"

dry_run=0
voice_nim=0
Expand Down Expand Up @@ -1353,7 +1353,7 @@ configure_and_verify_free_claude_code() {

add_uv_tool_bin_directory

for command_name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider; do
for command_name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update; do
[ -x "$tool_bin/$command_name" ] || fail "Free Claude Code installation did not create $tool_bin/$command_name."
done

Expand Down
1 change: 1 addition & 0 deletions scripts/uninstall.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ $FccCommands = @(
"fcc-grok",
"fcc-muse",
"fcc-aider",
"fcc-update",
"fcc-init",
"free-claude-code"
)
Expand Down
4 changes: 2 additions & 2 deletions scripts/uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ FCC_HOME_DIRNAME=".fcc"
FCC_MACOS_BUNDLE_ID="io.github.alishahryar1.free-claude-code"
FCC_MACOS_OWNER_FILE=".free-claude-code-owner"
# Include retired entry points so older installations are fully stopped and removed.
FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-init free-claude-code"
FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update fcc-init free-claude-code"

dry_run=0
uv_tool_bin=""
Expand Down Expand Up @@ -191,7 +191,7 @@ verify_fcc_commands_removed() {
fi

remaining=""
for command_name in $FCC_COMMANDS; do
for command_name in $FCC_COMMANDS fcc-update.cmd; do
command_path="$uv_tool_bin/$command_name"
if [ -e "$command_path" ] || [ -L "$command_path" ]; then
remaining="${remaining} ${command_path}"
Expand Down
4 changes: 4 additions & 0 deletions scripts/update/fcc-update
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
installer=$(curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh")
exec sh -c "$installer" sh "$@"
5 changes: 5 additions & 0 deletions scripts/update/fcc-update.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@echo off
(
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; & ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1')))" %*
call exit /b %%errorlevel%%
)
31 changes: 31 additions & 0 deletions src/free_claude_code/providers/open_router/client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""OpenRouter provider implementation."""

import json
from collections.abc import Mapping

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.core.anthropic import ReasoningReplayMode
from free_claude_code.core.diagnostics import extract_upstream_error_detail
from free_claude_code.core.failures import ExecutionFailure, FailureKind
from free_claude_code.core.reasoning import ReasoningEffort
from free_claude_code.providers.admission import ProviderAdmissionController
from free_claude_code.providers.base import ProviderConfig
Expand Down Expand Up @@ -34,6 +36,35 @@
class OpenRouterChatBehavior(OpenAIChatBehavior):
"""OpenRouter Chat adaptation without HTTP ownership."""

def failure_override(self, error: Exception) -> ExecutionFailure | None:
if getattr(error, "status_code", None) != 404:
return None
body = getattr(error, "body", None)
if isinstance(body, Mapping) and "error" in body:
if any(field in body for field in ("code", "message", "metadata")):
return None
body = body["error"]
if not isinstance(body, Mapping):
return None
code = body.get("code")
if not isinstance(code, int) or code != 404:
return None
if not isinstance(body.get("message"), str):
return None
metadata = body.get("metadata")
if (
not isinstance(metadata, Mapping)
or metadata.get("failed_routing_step") != "Filter by Image Support"
):
return None
return ExecutionFailure(
FailureKind.INVALID_REQUEST,
400,
"No OpenRouter endpoint for this request supports image input. "
"Remove the image or choose an image-capable model.",
False,
)

def reasoning_disable_rejected(self, error: Exception) -> bool:
detail = extract_upstream_error_detail(error)
if detail.status_code not in {None, 200}:
Expand Down
81 changes: 81 additions & 0 deletions tests/api/test_openrouter_image_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import asyncio
from unittest.mock import AsyncMock, patch

import pytest
from fastapi.testclient import TestClient

from free_claude_code.providers.open_router import OpenRouterProvider
from tests.api.support import create_test_app
from tests.providers.support import immediate_admission, make_provider_config
from tests.providers.test_open_router import image_routing_error_body, routing_error


@pytest.mark.parametrize(
("wire", "stream"), [("messages", False), ("messages", True), ("responses", True)]
)
@pytest.mark.parametrize("image_rejected", [True, False])
def test_openrouter_image_error_reaches_client_without_losing_image(
wire, stream, image_rejected
):
provider = OpenRouterProvider(
make_provider_config("test-key", "https://openrouter.ai/api/v1"),
admission=immediate_admission(),
)
body = (
image_routing_error_body()
if image_rejected
else {"code": 404, "message": "Model not found"}
)
create = AsyncMock(side_effect=routing_error(body))
image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nGQAAAAASUVORK5CYII="
url = f"data:image/png;base64,{image_data}"
payload = {"model": "open_router/test-model", "stream": stream}
if wire == "messages":
content = [
{"type": "text", "text": "Read this image"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": image_data,
},
},
]
payload.update(messages=[{"role": "user", "content": content}], max_tokens=100)
else:
content = [
{"type": "input_text", "text": "Read this image"},
{"type": "input_image", "image_url": url},
]
payload.update(
input=[{"role": "user", "content": content}], max_output_tokens=100
)
try:
with (
patch(
"free_claude_code.api.routes.resolve_provider", return_value=provider
),
patch.object(provider._client.chat.completions, "create", create),
TestClient(create_test_app()) as client,
):
response = client.post(f"/v1/{wire}", json=payload)
assert response.status_code == (400 if image_rejected else 404)
assert response.headers["x-should-retry"] == "false"
error = response.json()["error"]
assert error["type"] == (
"invalid_request_error" if image_rejected else "api_error"
)
assert body["message"] in error["message"]
assert response.headers["request-id"] in error["message"]
if image_rejected:
assert "Remove the image" in error["message"]
assert create.await_count == 1
assert any(
part.get("image_url", {}).get("url") == url
for message in create.await_args_list[0].kwargs["messages"]
if isinstance(message["content"], list)
for part in message["content"]
)
finally:
asyncio.run(provider.cleanup())
Loading
Loading