chore: import upstream snapshot with attribution
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:44 +08:00
commit 5296d0e97c
442 changed files with 94651 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
# NVIDIA NIM Config
NVIDIA_NIM_API_KEY=""
# OpenRouter Config
OPENROUTER_API_KEY=""
# Mistral La Plateforme Config (Experiment plan free tier rate limits; OpenAI-compatible at api.mistral.ai/v1)
MISTRAL_API_KEY=""
# Mistral Codestral (separate key from La Plateforme; OpenAI-compatible at codestral.mistral.ai/v1)
CODESTRAL_API_KEY=""
# DeepSeek Config (OpenAI-compatible Chat Completions at api.deepseek.com)
DEEPSEEK_API_KEY=""
# Kimi Config (OpenAI-compatible Chat Completions at api.moonshot.ai/v1)
KIMI_API_KEY=""
# Wafer Config (OpenAI-compatible Chat Completions at pass.wafer.ai/v1)
WAFER_API_KEY=""
# MiniMax Config (OpenAI-compatible Chat Completions at api.minimax.io/v1)
MINIMAX_API_KEY=""
# OpenCode Zen (opencode.ai/zen/v1) and OpenCode Go (opencode.ai/zen/go/v1) share OPENCODE_API_KEY
OPENCODE_API_KEY=""
# Vercel AI Gateway Config (OpenAI-compatible Chat Completions at ai-gateway.vercel.sh/v1)
AI_GATEWAY_API_KEY=""
# Hugging Face Inference Providers Config (OpenAI-compatible Chat Completions at router.huggingface.co/v1)
HUGGINGFACE_API_KEY=""
# Cohere Config (OpenAI-compatible Chat Completions at api.cohere.ai/compatibility/v1)
COHERE_API_KEY=""
# GitHub Models Config (OpenAI-compatible Chat Completions at models.github.ai/inference)
GITHUB_MODELS_TOKEN=""
# Z.ai Config (GLM Coding Plan OpenAI-compatible Chat Completions at api.z.ai/api/coding/paas/v4)
ZAI_API_KEY=""
# Fireworks AI Config (OpenAI-compatible Chat Completions at api.fireworks.ai/inference/v1)
FIREWORKS_API_KEY=""
# Cloudflare Workers AI Config (OpenAI-compatible Chat Completions at api.cloudflare.com/client/v4/accounts/<id>/ai/v1)
CLOUDFLARE_API_TOKEN=""
CLOUDFLARE_ACCOUNT_ID=""
# Gemini / Google AI Studio (OpenAI-compatible Chat Completions; see https://ai.google.dev/gemini-api/docs/openai)
GEMINI_API_KEY=""
# Groq Cloud (OpenAI-compatible Chat Completions; see https://console.groq.com/docs/openai)
GROQ_API_KEY=""
# SambaNova Cloud (OpenAI-compatible Chat Completions at api.sambanova.ai/v1)
SAMBANOVA_API_KEY=""
# Cerebras Inference (OpenAI-compatible Chat Completions; see https://inference-docs.cerebras.ai/resources/openai)
CEREBRAS_API_KEY=""
# LM Studio Config (local provider, no API key required)
LM_STUDIO_BASE_URL="http://localhost:1234/v1"
# Llama.cpp Config (local provider, no API key required)
LLAMACPP_BASE_URL="http://localhost:8080/v1"
# Ollama Config (local provider, no API key required)
OLLAMA_BASE_URL="http://localhost:11434"
# All Claude model requests are mapped to these models, plain model is fallback
# Format: provider_type/model/name
# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "lmstudio" | "llamacpp" | "ollama"
MODEL_OPUS=
MODEL_SONNET=
MODEL_HAIKU=
MODEL="nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
# Optional live smoke model overrides. Provider smoke runs once per configured
# provider even when MODEL/MODEL_* route to a different provider.
FCC_SMOKE_MODEL_NVIDIA_NIM=
FCC_SMOKE_MODEL_OPEN_ROUTER=
FCC_SMOKE_MODEL_MISTRAL=
FCC_SMOKE_MODEL_MISTRAL_REASONING=
FCC_SMOKE_MODEL_MISTRAL_CODESTRAL=
FCC_SMOKE_MODEL_DEEPSEEK=
FCC_SMOKE_MODEL_LMSTUDIO=
FCC_SMOKE_MODEL_LLAMACPP=
FCC_SMOKE_MODEL_OLLAMA=
FCC_SMOKE_MODEL_KIMI=
FCC_SMOKE_MODEL_WAFER=
FCC_SMOKE_MODEL_MINIMAX=
FCC_SMOKE_MODEL_OPENCODE=
FCC_SMOKE_MODEL_OPENCODE_GO=
FCC_SMOKE_MODEL_VERCEL=
FCC_SMOKE_MODEL_HUGGINGFACE=
FCC_SMOKE_MODEL_COHERE=
FCC_SMOKE_MODEL_GITHUB_MODELS=
FCC_SMOKE_MODEL_ZAI=
FCC_SMOKE_MODEL_FIREWORKS=
FCC_SMOKE_MODEL_CLOUDFLARE=
FCC_SMOKE_MODEL_GEMINI=
FCC_SMOKE_MODEL_GROQ=
FCC_SMOKE_MODEL_SAMBANOVA=
FCC_SMOKE_MODEL_CEREBRAS=
FCC_SMOKE_NIM_MODELS=
FCC_SMOKE_NIM_EXTRA_MODELS=
FCC_SMOKE_OPENROUTER_FREE_MODELS=
FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS=
# Thinking output
# Per-Claude-model switches for provider reasoning requests and Claude thinking blocks.
# Blank per-model switches inherit ENABLE_MODEL_THINKING.
ENABLE_OPUS_THINKING=
ENABLE_SONNET_THINKING=
ENABLE_HAIKU_THINKING=
ENABLE_MODEL_THINKING=true
# Provider config
# Per-provider proxy support: http and socks5, example: "http://username:password@host:port"
NVIDIA_NIM_PROXY=""
OPENROUTER_PROXY=""
MISTRAL_PROXY=""
CODESTRAL_PROXY=""
LMSTUDIO_PROXY=""
LLAMACPP_PROXY=""
KIMI_PROXY=""
WAFER_PROXY=""
MINIMAX_PROXY=""
OPENCODE_PROXY=""
OPENCODE_GO_PROXY=""
VERCEL_AI_GATEWAY_PROXY=""
HUGGINGFACE_PROXY=""
COHERE_PROXY=""
GITHUB_MODELS_PROXY=""
ZAI_PROXY=""
FIREWORKS_PROXY=""
CLOUDFLARE_PROXY=""
GEMINI_PROXY=""
GROQ_PROXY=""
SAMBANOVA_PROXY=""
CEREBRAS_PROXY=""
PROVIDER_RATE_LIMIT=1
PROVIDER_RATE_WINDOW=3
PROVIDER_MAX_CONCURRENCY=5
# HTTP client timeouts (seconds) for provider API requests
HTTP_READ_TIMEOUT=300
HTTP_WRITE_TIMEOUT=60
HTTP_CONNECT_TIMEOUT=60
# Optional server API key (Anthropic-style)
ANTHROPIC_AUTH_TOKEN="freecc"
# Open /admin in the default browser when fcc-server becomes healthy (set 0/false/no to disable)
FCC_OPEN_BROWSER=true
# Messaging Platform: "telegram" | "discord" | "none"
MESSAGING_PLATFORM="discord"
MESSAGING_RATE_LIMIT=1
MESSAGING_RATE_WINDOW=1
# Voice Note Transcription
VOICE_NOTE_ENABLED=false
# WHISPER_DEVICE: "cpu" | "cuda" | "nvidia_nim"
# - "cpu"/"cuda": Hugging Face transformers Whisper (offline, free; install with: uv sync --extra voice_local)
# - "nvidia_nim": NVIDIA NIM Whisper via Riva gRPC (requires NVIDIA_NIM_API_KEY; install with: uv sync --extra voice)
# (Independent of MODEL=nvidia_nim/...: that selects the *chat* provider; this selects voice STT only.)
WHISPER_DEVICE="nvidia_nim"
# WHISPER_MODEL:
# - For cpu/cuda: Hugging Face ID or short name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo)
# - For nvidia_nim: NVIDIA NIM model (e.g., "nvidia/parakeet-ctc-1.1b-asr", "openai/whisper-large-v3")
# - For nvidia_nim, default to "openai/whisper-large-v3" for best performance
WHISPER_MODEL="openai/whisper-large-v3"
# Telegram Config
TELEGRAM_BOT_TOKEN=""
ALLOWED_TELEGRAM_USER_ID=""
# Optional Telegram-only proxy.
# Supported schemes: http, https, socks4, socks5, socks5h.
# Example: "socks5://127.0.0.1:1080" or "https://user:password@host:port"
TELEGRAM_PROXY_URL=""
# Discord Config
DISCORD_BOT_TOKEN=""
ALLOWED_DISCORD_CHANNELS=""
# Agent Config
ALLOWED_DIR=""
FAST_PREFIX_DETECTION=true
ENABLE_NETWORK_PROBE_MOCK=true
ENABLE_TITLE_GENERATION_SKIP=true
ENABLE_SUGGESTION_MODE_SKIP=true
ENABLE_FILEPATH_EXTRACTION_MOCK=true
# Local Anthropic web_search / web_fetch handling (performs outbound HTTP; on by default)
ENABLE_WEB_SERVER_TOOLS=true
WEB_FETCH_ALLOWED_SCHEMES=http,https
WEB_FETCH_ALLOW_PRIVATE_NETWORKS=false
# Structured TRACE logs: lines with `"trace": true` merge ingress/routing/cli/provider/egress
# stages. Conversation text is logged in those payloads (verbatim). Values under keys named
# like ``api_key`` / ``authorization`` are redacted. Raw transport payloads still require
# the LOG_RAW_* toggles below.
#
# Verbose diagnostics (avoid logging raw prompts / SSE bodies in production)
DEBUG_PLATFORM_EDITS=false
DEBUG_SUBAGENT_STACK=false
# When true, also allows DEBUG-level httpx/httpcore/telegram log noise (not just payload logging).
LOG_RAW_API_PAYLOADS=false
LOG_RAW_SSE_EVENTS=false
# When true, log full exception text and tracebacks for unhandled errors (may leak request-derived data).
LOG_API_ERROR_TRACEBACKS=false
# When true, log message/transcription text previews in messaging adapters only (handler ingress always TRACEs verbatim text separately).
LOG_RAW_MESSAGING_CONTENT=false
# When true, log full Claude CLI stderr, non-JSON stdout lines, and parser error text.
LOG_RAW_CLI_DIAGNOSTICS=false
# When true, log full exception and CLI error message strings in messaging (may leak user content).
LOG_MESSAGING_ERROR_DETAILS=false
+13
View File
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+74
View File
@@ -0,0 +1,74 @@
# Branch protection: require every status check this workflow reports, for example:
# Ban suppressions and legacy annotations
# ruff-format, ruff-check, ty, pytest
# GitHub may prefix with the workflow name (e.g. "CI / ruff-format"); use the names the branch protection UI offers after a run.
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
no-suppressions-or-legacy-annotations:
name: Ban suppressions and legacy annotations
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
fetch-depth: 1
- name: "Fail on type ignores and legacy future annotations"
run: |
if grep -rE '# type: ignore|# ty: ignore|from __future__ import annotations' --include='*.py' . --exclude-dir=.venv --exclude-dir=.git; then
echo "::error::type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead."
exit 1
fi
exit 0
quality:
name: ${{ matrix.id }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- id: ruff-format
run: uv run ruff format --check
- id: ruff-check
run: uv run ruff check
- id: ty
run: uv run ty check
- id: pytest
run: uv run pytest -v --tb=short
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
fetch-depth: 1
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
with:
version: "0.11.15"
enable-cache: true
cache-python: true
- name: Run
run: ${{ matrix.run }}
+17
View File
@@ -0,0 +1,17 @@
__pycache__
.claude
.cursor
.pytest_cache
.ruff_cache
.serena
.venv
agent_workspace
.env
/logs/
server.log
/server.*.log
debug-*.log
.coverage
llama_cache
.smoke-results
.vscode
+1
View File
@@ -0,0 +1 @@
3.14.0
+105
View File
@@ -0,0 +1,105 @@
# AGENTIC DIRECTIVE
> Keep AGENTS.md and CLAUDE.md identical.
## CODING ENVIRONMENT
- Install astral uv using "curl -LsSf https://astral.sh/uv/install.sh | sh" if not already installed and if already installed then update it to the latest version
- Install Python 3.14.0 stable using `uv python install 3.14.0` if not already installed (requires uv >=0.9; see `[tool.uv] required-version` in `pyproject.toml`)
- Always use `uv run` to run files instead of the global `python` command.
- Current uv ruff formatter is set to py314 which has supports multiple exception types without paranthesis (except TypeError, ValueError:)
- Read `.env.example` for environment variables.
- All CI checks must pass; failing checks block merge.
- Add tests for new changes (including edge cases).
- Before pushing, prefer `./scripts/ci.sh` (macOS/Linux) or `.\scripts\ci.ps1` (Windows) to run the local CI sequence; requires `uv` on PATH. The local scripts run Ruff in repair mode (`ruff format`, then `ruff check --fix`) before type checking and tests.
- Use `--only` / `--skip` (PowerShell: `-Only` / `-Skip`) to run a subset when iterating; use `--dry-run` to print commands without running them.
- GitHub CI remains check-only for Ruff (`ruff format --check`, `ruff check`) so branch protection verifies committed code.
- Fall back to individual repair commands when debugging local failures: `uv run ruff format`, `uv run ruff check --fix`, `uv run ty check`, `uv run pytest -v --tb=short`. Use GitHub-style checks only when verifying enforcement locally: `uv run ruff format --check`, `uv run ruff check`.
- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue.
- Do not add `from __future__ import annotations`; Python 3.14 native lazy annotations are the project standard.
- All 5 check IDs are represented in `scripts/ci.sh` / `scripts/ci.ps1` and enforced in `tests.yml` on push/merge (parallel jobs: suppression grep, ruff-format, ruff-check, ty, pytest).
- GitHub CI runs on `push`, `pull_request`, and `merge_group` so required checks validate merge queue candidates before they land.
- Repository protection should use rulesets: a non-bypassable main integrity ruleset requires pull requests, merge queue, required checks, and blocks direct/force pushes to `main`; a separate review ruleset may allow `Alishahryar1`/admins to bypass review only.
- Required status checks: set **required status checks** to **all** of those statuses (e.g. **Ban suppressions and legacy annotations**, **ruff-format**, **ruff-check**, **ty**, **pytest**—use the exact labels GitHub shows, which may be prefixed with **CI /**). Remove **ci** from required checks if it was previously added for the old gate job.
## IDENTITY & CONTEXT
- You are an expert Software Architect and Systems Engineer.
- Goal: Zero-defect, root-cause-oriented engineering for bugs; test-driven engineering for new features. Think carefully; no need to rush.
- Code: Write the simplest code possible. Keep the codebase minimal and modular.
## ARCHITECTURE PRINCIPLES
- **Shared utilities**: Put shared Anthropic protocol logic in neutral `src/free_claude_code/core/anthropic/` modules. Do not have one provider import from another provider's utils.
- **Failure ownership**: Keep canonical failure semantics and redaction SDK-free in `core/`; providers alone classify SDK/HTTP failures and own retries; protocol/API adapters alone choose wire error types and commit-boundary serialization.
- **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste.
- **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside.
- **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`.
- **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`).
- **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters.
- **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code.
- **No type ignores**: Do not add `# type: ignore` or `# ty: ignore`. Fix the underlying type issue.
- **Python 3.14 annotations**: Do not use `from __future__ import annotations`; rely on native lazy annotations and fix circular import boundaries instead of hiding them with annotation stringization.
- **Imports**: Prefer top-level imports. Avoid `TYPE_CHECKING` and local imports for first-party or required dependencies; if a top-level import creates a cycle, move shared types/protocols to a neutral owner.
- **Complete migrations**: When moving modules, update imports to the new owner and remove old compatibility shims in the same change unless preserving a published interface is explicitly required.
- **Maximum Test Coverage**: There should be maximum test coverage for everything, preferably live smoke test coverage to catch bugs early
## COGNITIVE WORKFLOW
1. **ANALYZE**: Read relevant files. Do not guess.
2. **PLAN**: Map out the logic. Identify root cause or required changes. Order changes by dependency.
3. **EXECUTE**: Fix the cause, not the symptom. Execute incrementally with clear commits.
4. **VERIFY**: Run `./scripts/ci.sh` or `.\scripts\ci.ps1`, plus relevant smoke tests when needed. Confirm the fix via logs or output.
5. **SPECIFICITY**: Do exactly as much as asked; nothing more, nothing less.
6. **PROPAGATION**: Changes impact multiple files; propagate updates correctly.
7. **VERSION**: If the commit touches production files on `main`, bump semver in the same commit (see [Versioning](#versioning-main)).
## VERSIONING (MAIN)
Every commit on `main` that changes a **production file** must include a semver bump in **`pyproject.toml`** in the **same commit**. Do not merge or push prod changes without updating the version.
### Production files
These paths count as production (runtime, packaging, or install surface):
- `src/free_claude_code/api/`, `src/free_claude_code/cli/`, `src/free_claude_code/config/`, `src/free_claude_code/core/`, `src/free_claude_code/messaging/`, `src/free_claude_code/providers/`
- `src/free_claude_code/application/`
- `.env.example`
- `pyproject.toml` (dependencies, scripts, packaging)
- `scripts/install.sh`, `scripts/install.ps1`, `scripts/uninstall.sh`, `scripts/uninstall.ps1`, `scripts/ci.sh`, `scripts/ci.ps1`
These do **not** require a version bump on their own:
- `tests/`, `smoke/`
- Docs and assets: `README.md`, `assets/`, `AGENTS.md`, `CLAUDE.md`
- CI and repo config: `.github/`, `.gitignore`
If a single commit mixes production and non-production edits, still bump the version.
### Semver rules
Use `[project].version` as `MAJOR.MINOR.PATCH`:
- **PATCH** (`x.y.Z+1`): bug fixes, refactors with no user-visible behavior change, dependency updates, packaging/install fixes.
- **MINOR** (`x.Y+1.0`): backward-compatible features—new providers, admin fields, CLI commands, config options, or behavior additions.
- **MAJOR** (`X+1.0.0`): breaking changes—removed or renamed env vars, incompatible API/CLI/default changes, or migrations users must act on.
When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new capability.
### Required steps
1. Classify the change and choose the bump level.
2. Update `version` in `pyproject.toml`.
3. Run `uv lock` so `uv.lock` reflects the new package version.
4. Include the version and lockfile updates in the same commit as the production change.
Example commit on `main` after a packaging fix: bump `1.2.38``1.2.39`, run `uv lock`, commit together with the fix.
## SUMMARY STANDARDS
- Summaries must be technical and granular.
- Include: [Files Changed], [Logic Altered], [Verification Method], [Residual Risks] (if no residual risks then say none).
## TOOLS
- Prefer built-in tools (grep, read_file, etc.) over manual workflows. Check tool availability before use.
+1294
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
# AGENTIC DIRECTIVE
> Keep AGENTS.md and CLAUDE.md identical.
## CODING ENVIRONMENT
- Install astral uv using "curl -LsSf https://astral.sh/uv/install.sh | sh" if not already installed and if already installed then update it to the latest version
- Install Python 3.14.0 stable using `uv python install 3.14.0` if not already installed (requires uv >=0.9; see `[tool.uv] required-version` in `pyproject.toml`)
- Always use `uv run` to run files instead of the global `python` command.
- Current uv ruff formatter is set to py314 which has supports multiple exception types without paranthesis (except TypeError, ValueError:)
- Read `.env.example` for environment variables.
- All CI checks must pass; failing checks block merge.
- Add tests for new changes (including edge cases).
- Before pushing, prefer `./scripts/ci.sh` (macOS/Linux) or `.\scripts\ci.ps1` (Windows) to run the local CI sequence; requires `uv` on PATH. The local scripts run Ruff in repair mode (`ruff format`, then `ruff check --fix`) before type checking and tests.
- Use `--only` / `--skip` (PowerShell: `-Only` / `-Skip`) to run a subset when iterating; use `--dry-run` to print commands without running them.
- GitHub CI remains check-only for Ruff (`ruff format --check`, `ruff check`) so branch protection verifies committed code.
- Fall back to individual repair commands when debugging local failures: `uv run ruff format`, `uv run ruff check --fix`, `uv run ty check`, `uv run pytest -v --tb=short`. Use GitHub-style checks only when verifying enforcement locally: `uv run ruff format --check`, `uv run ruff check`.
- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue.
- Do not add `from __future__ import annotations`; Python 3.14 native lazy annotations are the project standard.
- All 5 check IDs are represented in `scripts/ci.sh` / `scripts/ci.ps1` and enforced in `tests.yml` on push/merge (parallel jobs: suppression grep, ruff-format, ruff-check, ty, pytest).
- GitHub CI runs on `push`, `pull_request`, and `merge_group` so required checks validate merge queue candidates before they land.
- Repository protection should use rulesets: a non-bypassable main integrity ruleset requires pull requests, merge queue, required checks, and blocks direct/force pushes to `main`; a separate review ruleset may allow `Alishahryar1`/admins to bypass review only.
- Required status checks: set **required status checks** to **all** of those statuses (e.g. **Ban suppressions and legacy annotations**, **ruff-format**, **ruff-check**, **ty**, **pytest**—use the exact labels GitHub shows, which may be prefixed with **CI /**). Remove **ci** from required checks if it was previously added for the old gate job.
## IDENTITY & CONTEXT
- You are an expert Software Architect and Systems Engineer.
- Goal: Zero-defect, root-cause-oriented engineering for bugs; test-driven engineering for new features. Think carefully; no need to rush.
- Code: Write the simplest code possible. Keep the codebase minimal and modular.
## ARCHITECTURE PRINCIPLES
- **Shared utilities**: Put shared Anthropic protocol logic in neutral `src/free_claude_code/core/anthropic/` modules. Do not have one provider import from another provider's utils.
- **Failure ownership**: Keep canonical failure semantics and redaction SDK-free in `core/`; providers alone classify SDK/HTTP failures and own retries; protocol/API adapters alone choose wire error types and commit-boundary serialization.
- **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste.
- **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside.
- **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`.
- **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`).
- **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters.
- **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code.
- **No type ignores**: Do not add `# type: ignore` or `# ty: ignore`. Fix the underlying type issue.
- **Python 3.14 annotations**: Do not use `from __future__ import annotations`; rely on native lazy annotations and fix circular import boundaries instead of hiding them with annotation stringization.
- **Imports**: Prefer top-level imports. Avoid `TYPE_CHECKING` and local imports for first-party or required dependencies; if a top-level import creates a cycle, move shared types/protocols to a neutral owner.
- **Complete migrations**: When moving modules, update imports to the new owner and remove old compatibility shims in the same change unless preserving a published interface is explicitly required.
- **Maximum Test Coverage**: There should be maximum test coverage for everything, preferably live smoke test coverage to catch bugs early
## COGNITIVE WORKFLOW
1. **ANALYZE**: Read relevant files. Do not guess.
2. **PLAN**: Map out the logic. Identify root cause or required changes. Order changes by dependency.
3. **EXECUTE**: Fix the cause, not the symptom. Execute incrementally with clear commits.
4. **VERIFY**: Run `./scripts/ci.sh` or `.\scripts\ci.ps1`, plus relevant smoke tests when needed. Confirm the fix via logs or output.
5. **SPECIFICITY**: Do exactly as much as asked; nothing more, nothing less.
6. **PROPAGATION**: Changes impact multiple files; propagate updates correctly.
7. **VERSION**: If the commit touches production files on `main`, bump semver in the same commit (see [Versioning](#versioning-main)).
## VERSIONING (MAIN)
Every commit on `main` that changes a **production file** must include a semver bump in **`pyproject.toml`** in the **same commit**. Do not merge or push prod changes without updating the version.
### Production files
These paths count as production (runtime, packaging, or install surface):
- `src/free_claude_code/api/`, `src/free_claude_code/cli/`, `src/free_claude_code/config/`, `src/free_claude_code/core/`, `src/free_claude_code/messaging/`, `src/free_claude_code/providers/`
- `src/free_claude_code/application/`
- `.env.example`
- `pyproject.toml` (dependencies, scripts, packaging)
- `scripts/install.sh`, `scripts/install.ps1`, `scripts/uninstall.sh`, `scripts/uninstall.ps1`, `scripts/ci.sh`, `scripts/ci.ps1`
These do **not** require a version bump on their own:
- `tests/`, `smoke/`
- Docs and assets: `README.md`, `assets/`, `AGENTS.md`, `CLAUDE.md`
- CI and repo config: `.github/`, `.gitignore`
If a single commit mixes production and non-production edits, still bump the version.
### Semver rules
Use `[project].version` as `MAJOR.MINOR.PATCH`:
- **PATCH** (`x.y.Z+1`): bug fixes, refactors with no user-visible behavior change, dependency updates, packaging/install fixes.
- **MINOR** (`x.Y+1.0`): backward-compatible features—new providers, admin fields, CLI commands, config options, or behavior additions.
- **MAJOR** (`X+1.0.0`): breaking changes—removed or renamed env vars, incompatible API/CLI/default changes, or migrations users must act on.
When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new capability.
### Required steps
1. Classify the change and choose the bump level.
2. Update `version` in `pyproject.toml`.
3. Run `uv lock` so `uv.lock` reflects the new package version.
4. Include the version and lockfile updates in the same commit as the production change.
Example commit on `main` after a packaging fix: bump `1.2.38``1.2.39`, run `uv lock`, commit together with the fix.
## SUMMARY STANDARDS
- Summaries must be technical and granular.
- Include: [Files Changed], [Logic Altered], [Verification Method], [Residual Risks] (if no residual risks then say none).
## TOOLS
- Prefer built-in tools (grep, read_file, etc.) over manual workflows. Check tool availability before use.
+63
View File
@@ -0,0 +1,63 @@
# Contributing
Thanks for helping improve Free Claude Code. Keep changes focused, test the behavior you change, and preserve the public Claude Code and Codex workflows.
## Before Opening A Pull Request
- Open an issue before proposing README changes.
- Do not open Docker integration pull requests.
- For bugs, include every model mapping, the active model when the failure occurred, the complete error, and reproducible steps.
- Add focused tests for behavior changes and relevant edge cases.
- Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing package boundaries, providers, protocol conversion, launchers, or messaging.
## Development Setup
Install [uv](https://docs.astral.sh/uv/) and Python 3.14, then run directly from the checkout:
```bash
git clone https://github.com/Alishahryar1/free-claude-code.git
cd free-claude-code
uv python install 3.14.0
uv run fcc-server
```
Use `uv run` for Python commands. Do not run the project with a global Python interpreter.
## Quality Checks
Run the complete local CI sequence before opening a pull request:
```bash
./scripts/ci.sh
```
```powershell
.\scripts\ci.ps1
```
Useful iteration flags are `--only`, `--skip`, and `--dry-run` on macOS/Linux, or `-Only`, `-Skip`, and `-DryRun` in PowerShell.
Individual repair and test commands:
```bash
uv run ruff format
uv run ruff check --fix
uv run ty check
uv run pytest -v --tb=short
```
GitHub CI runs Ruff in check-only mode and also bans `# type: ignore`, `# ty: ignore`, and legacy annotation workarounds. Fix underlying typing and import-boundary problems instead of suppressing them.
## Project Standards
- Target Python 3.14 and rely on native lazy annotations; do not add `from __future__ import annotations`.
- Python 3.14 supports multiple exception types without parentheses, such as `except TypeError, ValueError:`.
- Keep shared Anthropic protocol behavior under `src/free_claude_code/core/anthropic/` rather than importing utilities from another provider.
- Keep provider-specific configuration in the provider that owns it.
- Remove dead compatibility code when completing migrations unless preserving a published interface is explicitly required.
## Versioning
Changes to runtime code, packaging, dependencies, or install/CI scripts require a semantic version bump in `pyproject.toml` and a matching `uv lock` update in the same commit. Documentation, tests, smoke coverage, and repository configuration do not require a version bump by themselves.
See [ARCHITECTURE.md](ARCHITECTURE.md) for extension checklists and the full system design.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ali Khokhar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+431
View File
@@ -0,0 +1,431 @@
<div align="center">
# 🤖 Free Claude Code
Use Claude Code, Codex, Pi, editor extensions, or chat bots through your own provider-backed proxy.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![Python 3.14](https://img.shields.io/badge/python-3.14-3776ab.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json&style=for-the-badge)](https://github.com/astral-sh/uv)
[![Tested with Pytest](https://img.shields.io/badge/testing-Pytest-00c0ff.svg?style=for-the-badge)](https://github.com/Alishahryar1/free-claude-code/actions/workflows/tests.yml)
[![Type checking: Ty](https://img.shields.io/badge/type%20checking-ty-ffcc00.svg?style=for-the-badge)](https://pypi.org/project/ty/)
[![Code style: Ruff](https://img.shields.io/badge/code%20formatting-ruff-f5a623.svg?style=for-the-badge)](https://github.com/astral-sh/ruff)
[![Logging: Loguru](https://img.shields.io/badge/logging-loguru-4ecdc4.svg?style=for-the-badge)](https://github.com/Delgan/loguru)
Run your coding agents with free, paid, or local models. Choose and validate providers from one local Admin UI.
[Quick Start](#quick-start) · [Providers](#choose-a-provider) · [Clients](#connect-your-client) · [Integrations](#optional-integrations) · [Manage](#manage-your-installation)
</div>
<div align="center">
<img src="assets/pic.png" alt="Free Claude Code in action" width="700">
<p><em>Claude Code running through the Free Claude Code proxy.</em></p>
</div>
<div align="center">
<img src="assets/codex.png" alt="Codex CLI in action through Free Claude Code" width="700">
<p><em>Codex CLI using the local FCC Responses provider.</em></p>
</div>
<a id="model-picker"></a>
<div align="center">
<img src="assets/cc-model-picker.png" alt="Claude Code model picker showing gateway models" width="700">
<p><em>Claude Code native <code>/model</code> picker with FCC gateway models.</em></p>
</div>
<div align="center">
<img src="assets/codex-model-picker.png" alt="Codex model picker showing generated FCC model catalog" width="700">
<p><em>Codex native <code>/model</code> picker with the generated FCC catalog.</em></p>
</div>
## Star History
<div align="center">
<a href="https://star-history.com/#Alishahryar1/free-claude-code&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Alishahryar1/free-claude-code&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Alishahryar1/free-claude-code&type=Date">
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Alishahryar1/free-claude-code&type=Date" width="700">
</picture>
</a>
</div>
## What You Get
- Launch Claude Code with `fcc-claude`, Codex with `fcc-codex`, or Pi with `fcc-pi`.
- Switch among 24 cloud and local providers from the Admin UI.
- Use each coding agent's native model picker.
- Route Opus, Sonnet, Haiku, and fallback traffic to different models.
- Keep streaming, tool use, and reasoning support across compatible models.
- Connect Claude Code and Codex in VS Code or Claude Code through JetBrains ACP.
- Optionally run Claude Code sessions through Discord or Telegram with voice-note transcription.
- Protect the local proxy with optional token authentication.
## Quick Start
<a id="install"></a>
### 1. Install Or Update
macOS/Linux:
```bash
curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh
```
Windows PowerShell:
```powershell
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1")))
```
Re-run the same command whenever you want to update. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1).
### 2. Start The Server
```bash
fcc-server
```
To print the installed Free Claude Code version without starting the server,
run `fcc-server --version`.
Keep this process running. The startup log shows the Admin UI address:
```text
INFO: Admin UI: http://127.0.0.1:8082/admin (local-only)
```
Use the port shown in your terminal if it differs from `8082`.
<a id="nvidia-nim-provider"></a>
### 3. Configure NVIDIA NIM
1. Create an API key at [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys).
2. Open the Admin UI URL from the server log.
3. Paste the key into `NVIDIA_NIM_API_KEY`.
4. Leave `MODEL` on the default `nvidia_nim/nvidia/nemotron-3-super-120b-a12b`, or select another model.
5. Click **Validate**, then **Apply**.
<div align="center">
<img src="assets/admin-page.png" alt="Local admin UI for proxy settings" width="700">
</div>
### 4. Run Your Coding Agent
Claude Code:
```bash
fcc-claude
```
Codex:
```bash
fcc-codex
```
Pi:
```bash
fcc-pi
```
All three launchers use the current Admin UI settings. Use the agent's model picker to choose from the models FCC exposes. Normal CLI arguments still work, for example:
```bash
fcc-codex exec "hello"
```
`fcc-pi` registers FCC only for that Pi process; your existing Pi settings, sessions, credentials, and extensions remain unchanged.
## Choose A Provider
Enter the listed setting in the Admin UI, set `MODEL` to a provider-prefixed model ID, then click **Validate** and **Apply**. Provider names link to their key, model, or setup pages.
| Provider | Admin UI setting | Example `MODEL` |
| --- | --- | --- |
| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` |
| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` |
| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `DEEPSEEK_API_KEY` | `deepseek/deepseek-chat` |
| [Mistral La Plateforme](https://console.mistral.ai/) | `MISTRAL_API_KEY` | `mistral/devstral-small-latest` |
| [Mistral Codestral](https://console.mistral.ai/) | `CODESTRAL_API_KEY` | `mistral_codestral/codestral-latest` |
| [OpenCode Zen](https://opencode.ai/auth) | `OPENCODE_API_KEY` | `opencode/gpt-5.3-codex` |
| [OpenCode Go](https://opencode.ai/auth) | `OPENCODE_API_KEY` | `opencode_go/minimax-m2.7` |
| [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/models-and-providers) | `AI_GATEWAY_API_KEY` | `vercel/openai/gpt-5.5` |
| [Hugging Face Inference Providers](https://huggingface.co/settings/tokens) | `HUGGINGFACE_API_KEY` | `huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest` |
| [Cohere](https://dashboard.cohere.com/api-keys) | `COHERE_API_KEY` | `cohere/command-a-plus-05-2026` |
| [GitHub Models](https://github.com/marketplace?type=models) | `GITHUB_MODELS_TOKEN` | `github_models/openai/gpt-4.1` |
| [Wafer](https://wafer.ai/) | `WAFER_API_KEY` | `wafer/DeepSeek-V4-Pro` |
| [Kimi](https://platform.moonshot.ai/console/api-keys) | `KIMI_API_KEY` | `kimi/kimi-k2.5` |
| [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key) | `MINIMAX_API_KEY` | `minimax/MiniMax-M3` |
| [Cerebras Inference](https://cloud.cerebras.ai/) | `CEREBRAS_API_KEY` | `cerebras/gpt-oss-120b` |
| [Groq](https://console.groq.com/keys) | `GROQ_API_KEY` | `groq/llama-3.3-70b-versatile` |
| [SambaNova](https://cloud.sambanova.ai/apis) | `SAMBANOVA_API_KEY` | `sambanova/Meta-Llama-3.3-70B-Instruct` |
| [Fireworks AI](https://fireworks.ai/account/api-keys) | `FIREWORKS_API_KEY` | `fireworks/accounts/fireworks/models/llama-v3p3-70b-instruct` |
| [Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/) | `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` | `cloudflare/@cf/moonshotai/kimi-k2.6` |
| [Z.ai](https://z.ai/manage-apikey/apikey-list) | `ZAI_API_KEY` | `zai/glm-5.2` |
| [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>` |
| [Ollama](https://ollama.com/) | `OLLAMA_BASE_URL` | `ollama/<model-tag>` |
Important provider notes:
- Mistral Codestral uses a separate key from Mistral La Plateforme.
- OpenCode Zen and OpenCode Go share `OPENCODE_API_KEY` but use different model prefixes.
- Cloudflare requires both its API token and account ID.
- Prefer tool-capable models for coding agents. Local models also need enough context for the agent's system prompt and tool definitions.
<details>
<summary><strong>Local provider setup</strong></summary>
### LM Studio
Start LM Studio's local server, load a tool-capable model, and use the model identifier shown by LM Studio with the `lmstudio/` prefix. The default URL is `http://localhost:1234/v1`.
### llama.cpp
Start `llama-server` with its OpenAI-compatible Chat Completions API and enough context for the model. Use the local model ID with the `llamacpp/` prefix. `LLAMACPP_BASE_URL` defaults to `http://localhost:8080/v1`; FCC accepts either the server root or an explicit `/v1` suffix.
### Ollama
```bash
ollama pull llama3.1
ollama serve
```
Use the tag shown by `ollama list` with the `ollama/` prefix. `OLLAMA_BASE_URL` defaults to `http://localhost:11434`; FCC accepts either the root URL or an explicit `/v1` suffix.
</details>
### Optional Model-Tier Routing
`MODEL` is the fallback for every request. Set `MODEL_OPUS`, `MODEL_SONNET`, or `MODEL_HAIKU` to override individual Claude Code tiers; leave a tier blank to inherit `MODEL`.
For example, route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep `MODEL` on `zai/glm-5.2`.
<a id="connect-your-client"></a>
## Connect Your Client
For terminal use, start `fcc-server`, then run `fcc-claude`, `fcc-codex`, or `fcc-pi`. Use the guides below for editor integrations.
<details>
<summary><strong>Claude Code in VS Code</strong></summary>
Install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code). Open VS Code's user settings as JSON and add:
```json
"claudeCode.disableLoginPrompt": true,
"claudeCode.environmentVariables": [
{ "name": "ANTHROPIC_BASE_URL", "value": "http://localhost:8082" },
{ "name": "ANTHROPIC_AUTH_TOKEN", "value": "freecc" },
{ "name": "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "value": "1" },
{ "name": "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "value": "190000" },
{ "name": "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "value": "1" }
]
```
Match the port and authentication token to the Admin UI, then reload the extension.
</details>
<details>
<summary><strong>Codex in VS Code</strong></summary>
Install the [Codex extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). Create or edit `~/.codex/config.toml` (`%USERPROFILE%\.codex\config.toml` on Windows):
```toml
model_provider = "fcc"
model = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
[model_providers.fcc]
name = "Free Claude Code"
base_url = "http://127.0.0.1:8082/v1"
env_key = "FCC_CODEX_API_KEY"
wire_api = "responses"
```
Store the Admin UI authentication token in `~/.codex/auth.json` or its Windows equivalent:
```json
{
"FCC_CODEX_API_KEY": "freecc"
}
```
Match `model`, the port, and the token to the Admin UI, then restart VS Code. For WSL-backed Codex, edit the files inside WSL.
</details>
<details>
<summary><strong>Claude Code in JetBrains ACP</strong></summary>
Edit the installed Claude ACP configuration:
- Windows: `C:\Users\%USERNAME%\AppData\Roaming\JetBrains\acp-agents\installed.json`
- Linux/macOS: `~/.jetbrains/acp.json`
Set the environment for `acp.registry.claude-acp`:
```json
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:8082",
"ANTHROPIC_AUTH_TOKEN": "freecc",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "190000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
}
```
Match the port and token to the Admin UI, then restart the IDE.
</details>
<details>
<summary><strong>Claude Code still asks you to log in</strong></summary>
If Claude Code asks you to log in after you configure the FCC URL and token, open its state file:
- Windows: `%USERPROFILE%\.claude.json`
- macOS/Linux/WSL: `~/.claude.json`
Merge this property into the existing JSON without removing its other fields:
```json
"hasCompletedOnboarding": true
```
If the file does not exist, create it with a complete JSON object:
```json
{
"hasCompletedOnboarding": true
}
```
Restart Claude Code or the IDE after saving the file.
</details>
<a id="optional-integrations"></a>
## Optional Integrations
Configure integrations from **Admin UI → Messaging**, then click **Validate** and **Apply**.
<div align="center">
<img src="assets/admin-messaging.png" alt="Admin UI Messaging view with bot and voice settings" width="700">
</div>
<details>
<summary><strong>Discord bot</strong></summary>
1. Create a bot in the [Discord Developer Portal](https://discord.com/developers/applications).
2. Enable **Message Content Intent** and invite it with read, send,
message-history, and **Manage Messages** permissions so `/clear` can remove
user prompts.
3. Set **Messaging Platform** to **discord**.
4. Enter **Discord Bot Token**, **Allowed Discord Channels**, and an absolute **Allowed Directory**.
5. Apply the settings and restart the server if requested.
</details>
<details>
<summary><strong>Telegram bot</strong></summary>
1. Create a bot with [@BotFather](https://t.me/BotFather).
2. Get your numeric user ID from [@userinfobot](https://t.me/userinfobot).
In groups, grant the bot permission to delete messages.
3. Set **Messaging Platform** to **telegram**.
4. Enter **Telegram Bot Token**, **Allowed Telegram User ID**, and an absolute **Allowed Directory**.
5. Apply the settings and restart the server if requested.
</details>
### Messaging commands
| Usage | Behavior |
| --- | --- |
| `/stats` | Show session state. |
| Standalone `/stop` | Cancel all work. |
| Reply with `/stop` | Cancel only the selected request while other queued requests continue. |
| Standalone `/clear` | Reset all FCC state and remove every tracked message in that chat, including user prompts, voice notes, FCC replies, Telegram's online notice, and the clear command itself. |
| Reply with `/clear` | Delete the selected message and its literal platform reply subtree while preserving its ancestors and siblings. |
<details>
<summary><strong>Voice notes</strong></summary>
Re-run the installer with the voice backend you need.
macOS/Linux:
```bash
# NVIDIA NIM transcription
curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-nim
# Local Whisper on CPU or CUDA
curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-local
# Both backends
curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-all
# Local Whisper with the CUDA 13.0 PyTorch backend
curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-local --torch-backend cu130
```
Windows PowerShell:
```powershell
# NVIDIA NIM transcription
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceNim
# Local Whisper on CPU or CUDA
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceLocal
# Both backends
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceAll
# Local Whisper with the CUDA 13.0 PyTorch backend
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceLocal -TorchBackend cu130
```
Restart `fcc-server`. In **Admin UI → Messaging → Voice**, enable voice notes, select `cpu`, `cuda`, or `nvidia_nim`, and choose the Whisper model. Local gated models need `HUGGINGFACE_API_KEY`; NVIDIA NIM transcription needs `NVIDIA_NIM_API_KEY`.
</details>
## Manage Your Installation
### Update
Re-run the matching command from [Install Or Update](#install).
### Uninstall
Stop every running FCC command first. The uninstaller removes the FCC uv tool, verifies every FCC command is gone, and then deletes `~/.fcc/`. It leaves uv, Python, Claude Code, Codex, Pi, and shared PATH entries intact.
macOS/Linux:
```bash
curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/uninstall.sh" | sh
```
Windows PowerShell:
```powershell
& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/uninstall.ps1")))
```
## Project Links
- [Report bugs or request features](https://github.com/Alishahryar1/free-claude-code/issues)
- [Architecture and extension guide](ARCHITECTURE.md)
- [Contributing guide](CONTRIBUTING.md)
## License
MIT License. See [LICENSE](LICENSE) for details.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Alishahryar1/free-claude-code`
- 原始仓库:https://github.com/Alishahryar1/free-claude-code
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+57
View File
@@ -0,0 +1,57 @@
%%{init: {"theme": "base", "themeVariables": {"fontFamily": "Inter, ui-sans-serif, system-ui, Segoe UI, Arial", "background": "transparent", "primaryTextColor": "#172033", "lineColor": "#718096"}}}%%
flowchart LR
claudeClient(["Claude Code<br/>CLI / VS Code / JetBrains / Bots"])
codexClient(["Codex CLI<br/>VS Code extension"])
messagesApi["Anthropic API<br/>/v1/messages, /v1/models"]
responsesApi["OpenAI Responses API<br/>/v1/responses"]
subgraph proxy["Free Claude Code Proxy :8082"]
admin["Local Admin UI<br/>keys, models, status"]
config[("Managed Config<br/>~/.fcc/.env")]
convert["Responses Converter<br/>OpenAI to Anthropic"]
router{"Model Router<br/>Opus / Sonnet / Haiku"}
optimize["Request Optimizations<br/>cheap local probes"]
normalize["Protocol Normalizer<br/>streaming, tools, thinking"]
adapters["Provider Adapters<br/>Anthropic + OpenAI-compatible"]
end
subgraph hosted["Hosted Providers"]
nim(["NVIDIA NIM"])
kimi(["Kimi"])
wafer(["Wafer"])
openrouter(["OpenRouter"])
deepseek(["DeepSeek"])
end
subgraph local["Local Providers"]
lmstudio(["LM Studio"])
llamacpp(["llama.cpp"])
ollama(["Ollama"])
end
claudeClient -->|"Anthropic Messages"| messagesApi
codexClient -->|"OpenAI Responses"| responsesApi
messagesApi --> router
responsesApi --> convert
convert --> router
admin --> config
config --> router
router --> optimize
optimize --> normalize
normalize --> adapters
adapters --> hosted
adapters --> local
classDef client fill:#efe7ff,stroke:#7c3aed,stroke-width:2px,color:#2e1065;
classDef api fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#172554;
classDef proxy fill:#ecfeff,stroke:#0891b2,stroke-width:2px,color:#164e63;
classDef config fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f;
classDef transform fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d;
classDef provider fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12;
class claudeClient,codexClient client;
class messagesApi,responsesApi api;
class admin,router proxy;
class config config;
class convert,optimize,normalize,adapters transform;
class nim,kimi,wafer,openrouter,deepseek,lmstudio,llamacpp,ollama provider;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 460 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+131
View File
@@ -0,0 +1,131 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "free-claude-code"
version = "4.0.0"
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
readme = "README.md"
requires-python = ">=3.14.0"
dependencies = [
"fastapi[standard]>=0.139.0",
"uvicorn>=0.50.0",
"httpx[socks]>=0.28.1",
"markdown-it-py>=4.2.0",
"pydantic>=2.13.4",
"python-dotenv>=1.2.2",
"tiktoken>=0.13.0",
"python-telegram-bot>=22.8",
"discord.py>=2.7.1",
"pydantic-settings>=2.14.2",
"openai>=2.44.0",
"loguru>=0.7.0",
"aiohttp>=3.14.1",
"jsonschema>=4.25.0",
]
[project.scripts]
fcc-server = "free_claude_code.cli.entrypoints:serve"
free-claude-code = "free_claude_code.cli.entrypoints:serve"
fcc-init = "free_claude_code.cli.entrypoints:init"
fcc-claude = "free_claude_code.cli.launchers.claude:launch"
fcc-codex = "free_claude_code.cli.launchers.codex:launch"
fcc-pi = "free_claude_code.cli.launchers.pi:launch"
[project.optional-dependencies]
voice = [
"grpcio>=1.81.1",
"grpcio-tools>=1.81.1",
"nvidia-riva-client>=2.26.0",
]
voice_local = [
"torch>=2.12.1",
"transformers>=5.13.0",
"accelerate>=1.14.0",
"librosa>=0.10.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/free_claude_code"]
[tool.hatch.build.targets.wheel.force-include]
".env.example" = "free_claude_code/config/env.example"
[tool.uv]
required-version = ">=0.11.0"
[tool.uv.sources]
torch = { index = "pytorch-cu130" }
[[tool.uv.index]]
name = "pytorch-cu130"
url = "https://download.pytorch.org/whl/cu130"
explicit = true
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"ty>=0.0.56",
"ruff>=0.15.20",
"pytest-xdist>=3.8.0",
]
[tool.ruff]
target-version = "py314"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes (undefined names, unused imports)
"I", # isort (import ordering)
"UP", # pyupgrade (modernise syntax for target Python version)
"B", # flake8-bugbear (common bugs and anti-patterns)
"C4", # flake8-comprehensions (idiomatic comprehensions)
"SIM", # flake8-simplify (simplifiable code patterns)
"PERF", # Perflint (performance anti-patterns)
"RUF", # Ruff-specific rules
]
ignore = [
"E501", # line too long — enforced by the formatter instead
"B008", # FastAPI Depends() in argument defaults is intentional
"RUF006", # fire-and-forget tasks intentionally not awaited
]
[tool.ruff.lint.isort]
known-first-party = ["free_claude_code", "smoke"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
skip-magic-trailing-comma = false
[tool.pytest.ini_options]
pythonpath = ["src"]
addopts = "-n auto"
testpaths = ["tests"]
markers = [
"live: opt-in local smoke tests that can touch real services",
"interactive: smoke tests requiring manual user interaction",
"provider: live provider checks",
"messaging: live messaging platform checks",
"cli: CLI integration checks",
"clients: client compatibility checks",
"voice: voice transcription checks",
"contract: deterministic feature contract checks",
"smoke_target(name): route a smoke test behind FCC_SMOKE_TARGETS",
"xdist_group(name): group smoke provider items under pytest-xdist --dist=loadgroup",
]
[tool.ty.environment]
python-version = "3.14"
[tool.ty.analysis]
# Optional voice_local extra: torch, transformers, librosa for local whisper transcription
# Optional voice extra: nvidia-riva-client for nvidia_nim transcription provider
allowed-unresolved-imports = ["torch", "transformers", "librosa", "riva.client"]
+210
View File
@@ -0,0 +1,210 @@
param(
[string[]] $Only = @(),
[string[]] $Skip = @(),
[switch] $DryRun,
[switch] $Help,
[Parameter(ValueFromRemainingArguments = $true)]
[object[]] $RemainingArgs = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$CheckOrder = @(
"suppressions",
"ruff-format",
"ruff-check",
"ty",
"pytest"
)
function Show-Usage {
@"
Usage: ci.ps1 [options]
Runs the local sequence for the same check IDs enforced by GitHub CI.
Requires uv on PATH when running ruff, ty, or pytest checks.
Local ruff checks repair formatting and autofixable lint before later checks.
Checks (in order):
suppressions Ban type ignores and legacy future annotations
ruff-format uv run ruff format
ruff-check uv run ruff check --fix
ty uv run ty check
pytest uv run pytest -v --tb=short
Options:
-Only ID Run only the given check (repeatable)
-Skip ID Skip the given check (repeatable)
-DryRun Print commands without running them.
-Help Show this help text.
"@
}
function Write-Step {
param([string] $Message)
Write-Host ""
Write-Host "==> $Message"
}
function Format-Argument {
param([string] $Value)
if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]-]+$') {
return $Value
}
return "'" + ($Value -replace "'", "''") + "'"
}
function Invoke-CiCommand {
param(
[string] $FilePath,
[string[]] $Arguments = @()
)
$parts = @($FilePath) + $Arguments
$commandText = ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " "
Write-Host "+ $commandText"
if (-not $DryRun) {
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $commandText"
}
}
}
function Test-ValidCheckId {
param([string] $CheckId)
return $CheckOrder -contains $CheckId
}
function Assert-ValidCheckId {
param([string] $CheckId)
if (-not (Test-ValidCheckId $CheckId)) {
throw "unknown check id: $CheckId (expected one of: $($CheckOrder -join ', '))"
}
}
function Test-ShouldRunCheck {
param([string] $CheckId)
if ($Only.Count -gt 0 -and ($Only -notcontains $CheckId)) {
return $false
}
if ($Skip -contains $CheckId) {
return $false
}
return $true
}
function Assert-UvAvailable {
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
throw "uv is required but was not found on PATH. Install uv first (see README or scripts/install.ps1)."
}
}
function Test-SelectedChecksNeedUv {
if ($DryRun) {
return $false
}
foreach ($checkId in $CheckOrder) {
if ((Test-ShouldRunCheck $checkId) -and $checkId -ne "suppressions") {
return $true
}
}
return $false
}
function Invoke-SuppressionsCheck {
Write-Step "Ban suppressions and legacy annotations"
$pattern = '# type: ignore|# ty: ignore|from __future__ import annotations'
Write-Host "+ Get-ChildItem -Recurse -Filter *.py (excluding .venv, .git) | Select-String '$pattern'"
if (-not $DryRun) {
$matches = Get-ChildItem -Path . -Recurse -Filter *.py -File |
Where-Object {
$full = $_.FullName
$full -notmatch '[\\/]\.venv[\\/]' -and
$full -notmatch '[\\/]\.git[\\/]'
} |
Select-String -Pattern $pattern
if ($matches) {
$matches | ForEach-Object { Write-Host $_.Line }
throw "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead."
}
}
}
function Invoke-RuffFormatCheck {
Write-Step "ruff format"
Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ruff", "format")
}
function Invoke-RuffLintCheck {
Write-Step "ruff check --fix"
Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ruff", "check", "--fix")
}
function Invoke-TyCheck {
Write-Step "ty check"
Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ty", "check")
}
function Invoke-PytestCheck {
Write-Step "pytest"
Invoke-CiCommand -FilePath "uv" -Arguments @("run", "pytest", "-v", "--tb=short")
}
function Invoke-Check {
param([string] $CheckId)
switch ($CheckId) {
"suppressions" { Invoke-SuppressionsCheck }
"ruff-format" { Invoke-RuffFormatCheck }
"ruff-check" { Invoke-RuffLintCheck }
"ty" { Invoke-TyCheck }
"pytest" { Invoke-PytestCheck }
default { throw "unknown check id: $CheckId" }
}
}
if ($Help) {
Show-Usage
return
}
if ($RemainingArgs.Count -gt 0) {
Show-Usage
throw "Unknown option: $($RemainingArgs -join ' ')"
}
foreach ($checkId in $Only) {
Assert-ValidCheckId $checkId
}
foreach ($checkId in $Skip) {
Assert-ValidCheckId $checkId
}
if (Test-SelectedChecksNeedUv) {
Assert-UvAvailable
}
foreach ($checkId in $CheckOrder) {
if (Test-ShouldRunCheck $checkId) {
Invoke-Check $checkId
}
}
Write-Host ""
Write-Host "All selected CI checks passed."
Executable
+212
View File
@@ -0,0 +1,212 @@
#!/bin/sh
set -eu
CHECK_ORDER="suppressions ruff-format ruff-check ty pytest"
dry_run=0
only_checks=""
skip_checks=""
show_usage() {
cat <<'USAGE'
Usage: ci.sh [options]
Runs the local sequence for the same check IDs enforced by GitHub CI.
Requires uv on PATH when running ruff, ty, or pytest checks.
Local ruff checks repair formatting and autofixable lint before later checks.
Checks (in order):
suppressions Ban type ignores and legacy future annotations
ruff-format uv run ruff format
ruff-check uv run ruff check --fix
ty uv run ty check
pytest uv run pytest -v --tb=short
Options:
--only ID Run only the given check (repeatable)
--skip ID Skip the given check (repeatable)
--dry-run Print commands without running them.
--help Show this help text.
USAGE
}
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
step() {
printf '\n==> %s\n' "$1"
}
quote_arg() {
case "$1" in
*[!A-Za-z0-9_./:@%+=,-]*|"")
escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '"%s"' "$escaped"
;;
*)
printf '%s' "$1"
;;
esac
}
print_command() {
printf '+'
for arg in "$@"; do
printf ' '
quote_arg "$arg"
done
printf '\n'
}
run() {
print_command "$@"
if [ "$dry_run" -eq 0 ]; then
"$@"
fi
}
valid_check_id() {
case "$1" in
suppressions | ruff-format | ruff-check | ty | pytest) return 0 ;;
*) return 1 ;;
esac
}
validate_check_id() {
if ! valid_check_id "$1"; then
fail "unknown check id: $1 (expected one of: $CHECK_ORDER)"
fi
}
contains_check_id() {
list=$1
id=$2
case " $list " in
*" $id "*) return 0 ;;
*) return 1 ;;
esac
}
should_run_check() {
check_id=$1
if [ -n "$only_checks" ] && ! contains_check_id "$only_checks" "$check_id"; then
return 1
fi
if contains_check_id "$skip_checks" "$check_id"; then
return 1
fi
return 0
}
assert_uv_available() {
if ! command -v uv >/dev/null 2>&1; then
fail "uv is required but was not found on PATH. Install uv first (see README or scripts/install.sh)."
fi
}
selected_checks_need_uv() {
if [ "$dry_run" -ne 0 ]; then
return 1
fi
for check_id in $CHECK_ORDER; do
if should_run_check "$check_id" && [ "$check_id" != "suppressions" ]; then
return 0
fi
done
return 1
}
run_suppressions() {
step "Ban suppressions and legacy annotations"
pattern='# type: ignore|# ty: ignore|from __future__ import annotations'
print_command grep -rE "$pattern" --include='*.py' . \
--exclude-dir=.venv --exclude-dir=.git
if [ "$dry_run" -eq 0 ]; then
if grep -rE "$pattern" --include='*.py' . \
--exclude-dir=.venv --exclude-dir=.git; then
fail "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead."
fi
fi
}
run_ruff_format() {
step "ruff format"
run uv run ruff format
}
run_ruff_check() {
step "ruff check --fix"
run uv run ruff check --fix
}
run_ty() {
step "ty check"
run uv run ty check
}
run_pytest() {
step "pytest"
run uv run pytest -v --tb=short
}
run_check() {
case "$1" in
suppressions) run_suppressions ;;
ruff-format) run_ruff_format ;;
ruff-check) run_ruff_check ;;
ty) run_ty ;;
pytest) run_pytest ;;
*) fail "unknown check id: $1" ;;
esac
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--only)
shift
[ "$#" -gt 0 ] || fail "--only requires a check id."
validate_check_id "$1"
only_checks="${only_checks} $1"
;;
--skip)
shift
[ "$#" -gt 0 ] || fail "--skip requires a check id."
validate_check_id "$1"
skip_checks="${skip_checks} $1"
;;
--dry-run)
dry_run=1
;;
--help|-h)
show_usage
exit 0
;;
*)
show_usage >&2
fail "unknown option: $1"
;;
esac
shift
done
}
parse_args "$@"
if selected_checks_need_uv; then
assert_uv_available
fi
for check_id in $CHECK_ORDER; do
if should_run_check "$check_id"; then
run_check "$check_id"
fi
done
printf '\nAll selected CI checks passed.\n'
+557
View File
@@ -0,0 +1,557 @@
param(
[switch] $VoiceNim,
[switch] $VoiceLocal,
[switch] $VoiceAll,
[string] $TorchBackend = "",
[switch] $DryRun,
[switch] $Help,
[Parameter(ValueFromRemainingArguments = $true)]
[object[]] $RemainingArgs = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$RepoArchiveUrl = "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"
$PythonVersion = "3.14.0"
$MinUvVersion = "0.11.0"
$ClaudeInstallUrl = "https://claude.ai/install.ps1"
$CodexInstallUrl = "https://chatgpt.com/codex/install.ps1"
$PiInstallUrl = "https://pi.dev/install.ps1"
$UvInstallUrl = "https://astral.sh/uv/install.ps1"
function Show-Usage {
@"
Usage: install.ps1 [options]
Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code.
Options:
-VoiceNim Install NVIDIA NIM voice transcription support.
-VoiceLocal Install local Whisper voice transcription support.
-VoiceAll Install all voice transcription backends.
-TorchBackend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice.
-DryRun Print commands without running them.
-Help Show this help text.
"@
}
function Write-Step {
param([string] $Message)
Write-Host ""
Write-Host "==> $Message"
}
function Format-Argument {
param([string] $Value)
if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') {
return $Value
}
return "'" + ($Value -replace "'", "''") + "'"
}
function Format-Command {
param(
[string] $FilePath,
[string[]] $Arguments = @()
)
$parts = @($FilePath) + $Arguments
return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " "
}
function Invoke-NativeCommand {
param(
[string] $FilePath,
[string[]] $Arguments = @()
)
$commandText = Format-Command -FilePath $FilePath -Arguments $Arguments
Write-Host "+ $commandText"
if ($DryRun) {
return
}
$global:LASTEXITCODE = 0
& $FilePath @Arguments
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "Command failed with exit code ${exitCode}: $commandText"
}
}
function Invoke-NativeCapture {
param(
[string] $FilePath,
[string[]] $Arguments = @()
)
$commandText = Format-Command -FilePath $FilePath -Arguments $Arguments
Write-Host "+ $commandText"
$global:LASTEXITCODE = 0
$output = & $FilePath @Arguments
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "Command failed with exit code ${exitCode}: $commandText"
}
return ($output | Out-String).Trim()
}
function Get-ApplicationCommand {
param([string] $Name)
$commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue)
if ($commands.Count -eq 0) {
return $null
}
return $commands[0]
}
function Get-PowerShellExecutable {
param([string] $PowerShellHome = $PSHOME)
$executableName = if ($PSVersionTable.PSEdition -eq "Core") {
"pwsh.exe"
}
else {
"powershell.exe"
}
$bundledExecutable = Join-Path $PowerShellHome $executableName
if (Test-Path -LiteralPath $bundledExecutable -PathType Leaf) {
return $bundledExecutable
}
$pathCommand = Get-ApplicationCommand ([IO.Path]::GetFileNameWithoutExtension($executableName))
if ($pathCommand) {
return $pathCommand.Source
}
throw "Unable to locate a PowerShell executable for the downloaded installer."
}
function Add-PathEntry {
param([string] $PathEntry)
if ([string]::IsNullOrWhiteSpace($PathEntry)) {
return
}
$separator = [IO.Path]::PathSeparator
$entries = @()
if (-not [string]::IsNullOrEmpty($env:Path)) {
$entries = $env:Path -split [regex]::Escape([string] $separator)
}
if ($entries -notcontains $PathEntry) {
$env:Path = "$PathEntry$separator$env:Path"
}
}
function Add-KnownBinDirectories {
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin")
}
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
Add-PathEntry (Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin")
Add-PathEntry (Join-Path $env:LOCALAPPDATA "pi-node\current")
}
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
Add-PathEntry (Join-Path $env:APPDATA "npm")
}
}
function Add-PiBinDirectories {
if ($DryRun) {
return
}
Add-KnownBinDirectories
$npm = Get-ApplicationCommand "npm"
if (-not $npm) {
return
}
$prefix = (& $npm.Source prefix -g 2>$null | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) {
$prefix = (& $npm.Source config get prefix 2>$null | Out-String).Trim()
}
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($prefix)) {
Add-PathEntry $prefix
}
}
function Invoke-DownloadedPowerShellInstaller {
param(
[string] $Url,
[string] $Name,
[switch] $NonInteractive
)
if ($DryRun) {
Write-Host "+ irm $Url -OutFile <temporary-script>"
$prefix = if ($NonInteractive) { "CODEX_NON_INTERACTIVE=1 " } else { "" }
Write-Host "+ ${prefix}powershell -NoProfile -ExecutionPolicy Bypass -File <temporary-script>"
return
}
$temporaryScript = Join-Path ([IO.Path]::GetTempPath()) ("fcc-install-" + [guid]::NewGuid().ToString("N") + ".ps1")
try {
Write-Host "+ irm $Url -OutFile $(Format-Argument $temporaryScript)"
Invoke-RestMethod -Uri $Url -OutFile $temporaryScript -ErrorAction Stop
if ((-not (Test-Path -LiteralPath $temporaryScript)) -or ((Get-Item -LiteralPath $temporaryScript).Length -eq 0)) {
throw "The downloaded $Name installer was empty."
}
$powerShellPath = Get-PowerShellExecutable
$hadNonInteractive = Test-Path Env:CODEX_NON_INTERACTIVE
$previousNonInteractive = $env:CODEX_NON_INTERACTIVE
try {
if ($NonInteractive) {
$env:CODEX_NON_INTERACTIVE = "1"
}
Invoke-NativeCommand -FilePath $powerShellPath -Arguments @(
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
$temporaryScript
)
}
finally {
if ($hadNonInteractive) {
$env:CODEX_NON_INTERACTIVE = $previousNonInteractive
}
else {
Remove-Item Env:CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue
}
}
}
finally {
Remove-Item -LiteralPath $temporaryScript -Force -ErrorAction SilentlyContinue
}
}
function Confirm-Application {
param(
[string] $CommandName,
[string] $DisplayName
)
if ($DryRun) {
Write-Host "+ $CommandName --version"
return
}
$command = Get-ApplicationCommand $CommandName
if (-not $command) {
throw "$DisplayName was installed, but '$CommandName' is not available on PATH."
}
Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version")
}
function Test-PiApplication {
param($Command)
try {
$helpOutput = (& $Command.Source --help 2>$null | Out-String)
}
catch {
return $false
}
return (
$LASTEXITCODE -eq 0 -and
$helpOutput.Contains("--extension") -and
$helpOutput.Contains("--models")
)
}
function Confirm-PiApplication {
if ($DryRun) {
Write-Host "+ pi --help (verify --extension and --models support)"
Write-Host "+ pi --version"
return
}
$command = Get-ApplicationCommand "pi"
if (-not $command) {
throw "Pi was installed, but 'pi' is not available on PATH."
}
if (-not (Test-PiApplication $command)) {
throw "The 'pi' command at '$($command.Source)' is not a compatible Pi Coding Agent."
}
Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version")
}
function Ensure-ClaudeCode {
if (Get-ApplicationCommand "claude") {
Write-Host "Claude Code already found on PATH; verifying it."
}
else {
Invoke-DownloadedPowerShellInstaller -Url $ClaudeInstallUrl -Name "Claude Code"
Add-KnownBinDirectories
}
Confirm-Application -CommandName "claude" -DisplayName "Claude Code"
}
function Ensure-Codex {
if (Get-ApplicationCommand "codex") {
Write-Host "Codex already found on PATH; verifying it."
}
else {
Invoke-DownloadedPowerShellInstaller -Url $CodexInstallUrl -Name "Codex" -NonInteractive
Add-KnownBinDirectories
}
Confirm-Application -CommandName "codex" -DisplayName "Codex"
}
function Ensure-Pi {
$existingPi = Get-ApplicationCommand "pi"
if ($existingPi -and ($DryRun -or (Test-PiApplication $existingPi))) {
Write-Host "Pi already found on PATH; verifying it."
}
else {
if ($existingPi) {
Write-Host "The existing 'pi' command at '$($existingPi.Source)' is not Pi Coding Agent; installing Pi."
}
Invoke-DownloadedPowerShellInstaller -Url $PiInstallUrl -Name "Pi"
Add-PiBinDirectories
}
Confirm-PiApplication
}
function Convert-UvVersionOutput {
param([string] $Output)
if ([string]::IsNullOrWhiteSpace($Output)) {
return ""
}
if ($Output -match '(?m)(?:^|\s)(?:uv\s+)?(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?)\b') {
return $Matches["version"]
}
return ""
}
function Get-UvVersion {
param([string] $UvPath)
$output = Invoke-NativeCapture -FilePath $UvPath -Arguments @("--version")
$version = Convert-UvVersionOutput $output
if ([string]::IsNullOrWhiteSpace($version)) {
throw "uv is present, but 'uv --version' did not return a valid version."
}
return $version
}
function Test-UvVersionAtLeast {
param(
[string] $Version,
[string] $Minimum
)
$normalizedVersion = (Convert-UvVersionOutput $Version) -replace '[-+].*$', ''
$normalizedMinimum = (Convert-UvVersionOutput $Minimum) -replace '[-+].*$', ''
if ([string]::IsNullOrWhiteSpace($normalizedVersion) -or [string]::IsNullOrWhiteSpace($normalizedMinimum)) {
throw "Unable to compare uv versions."
}
return ([version] $normalizedVersion) -ge ([version] $normalizedMinimum)
}
function Confirm-Uv {
if ($DryRun) {
Write-Host "+ uv --version"
return
}
$uvCommand = Get-ApplicationCommand "uv"
if (-not $uvCommand) {
throw "uv was installed, but it is not available on PATH."
}
$version = Get-UvVersion $uvCommand.Source
if (-not (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion)) {
throw "uv $MinUvVersion or newer is required; found uv $version after installation."
}
Write-Host "Verified uv $version."
}
function Ensure-Uv {
if ($DryRun) {
if (Get-ApplicationCommand "uv") {
Write-Host "+ uv --version"
Write-Host "A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer."
}
else {
Write-Host "uv is not installed; the current standalone uv would be installed."
Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv"
Confirm-Uv
}
return
}
$uvCommand = Get-ApplicationCommand "uv"
if ($uvCommand) {
$version = Get-UvVersion $uvCommand.Source
if (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion) {
Write-Host "uv $version already satisfies >=$MinUvVersion; leaving it unchanged."
return
}
Write-Host "uv $version is below $MinUvVersion; installing the current standalone uv."
}
else {
Write-Host "uv is not installed; installing the current standalone uv."
}
Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv"
Add-KnownBinDirectories
Confirm-Uv
}
function Get-PackageSpec {
$includeNim = $VoiceNim
$includeLocal = $VoiceLocal
if ($VoiceAll) {
$includeNim = $true
$includeLocal = $true
}
if ($includeNim -and $includeLocal) {
return "free-claude-code[voice,voice_local] @ $RepoArchiveUrl"
}
if ($includeNim) {
return "free-claude-code[voice] @ $RepoArchiveUrl"
}
if ($includeLocal) {
return "free-claude-code[voice_local] @ $RepoArchiveUrl"
}
return "free-claude-code @ $RepoArchiveUrl"
}
function Install-FreeClaudeCode {
$packageSpec = Get-PackageSpec
$arguments = @(
"tool",
"install",
"--force",
"--refresh-package",
"free-claude-code",
"--python",
$PythonVersion
)
if (-not [string]::IsNullOrWhiteSpace($TorchBackend)) {
$arguments += @("--torch-backend", $TorchBackend)
}
$arguments += $packageSpec
$uvPath = "uv"
if (-not $DryRun) {
$uvCommand = Get-ApplicationCommand "uv"
if (-not $uvCommand) {
throw "uv is not available for the Free Claude Code installation."
}
$uvPath = $uvCommand.Source
}
Invoke-NativeCommand -FilePath $uvPath -Arguments $arguments
}
function Configure-AndConfirmFreeClaudeCode {
if ($DryRun) {
Write-Host "+ uv tool update-shell"
Write-Host "+ uv tool dir --bin"
Write-Host "+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory"
Write-Host "+ fcc-server --version"
return
}
$uvCommand = Get-ApplicationCommand "uv"
if (-not $uvCommand) {
throw "uv is not available for PATH configuration."
}
Invoke-NativeCommand -FilePath $uvCommand.Source -Arguments @("tool", "update-shell")
$toolBin = Invoke-NativeCapture -FilePath $uvCommand.Source -Arguments @("tool", "dir", "--bin")
if ([string]::IsNullOrWhiteSpace($toolBin)) {
throw "uv returned an empty tool bin directory."
}
Add-PathEntry $toolBin
$toolBinPath = ([IO.Path]::GetFullPath($toolBin)).TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar
)
$installedCommands = @{}
foreach ($commandName in @("fcc-server", "fcc-claude", "fcc-codex", "fcc-pi")) {
$command = Get-ApplicationCommand $commandName
if (-not $command) {
throw "Free Claude Code installation did not create '$commandName'."
}
$commandDirectory = ([IO.Path]::GetFullPath((Split-Path -Parent $command.Source))).TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar
)
if (-not $commandDirectory.Equals($toolBinPath, [StringComparison]::OrdinalIgnoreCase)) {
throw "'$commandName' resolved outside the uv tool bin directory: $($command.Source)"
}
$installedCommands[$commandName] = $command.Source
}
Invoke-NativeCommand -FilePath $installedCommands["fcc-server"] -Arguments @("--version")
}
if ($Help) {
Show-Usage
return
}
if ($RemainingArgs.Count -gt 0) {
Show-Usage
throw "Unknown option: $($RemainingArgs -join ' ')"
}
if ((-not [string]::IsNullOrWhiteSpace($TorchBackend)) -and (-not ($VoiceLocal -or $VoiceAll))) {
throw "-TorchBackend requires -VoiceLocal or -VoiceAll."
}
Add-KnownBinDirectories
Write-Step "Ensuring Claude Code is installed"
Ensure-ClaudeCode
Write-Step "Ensuring Codex is installed"
Ensure-Codex
Write-Step "Ensuring Pi is installed"
Ensure-Pi
Write-Step "Ensuring uv $MinUvVersion or newer is installed"
Ensure-Uv
Write-Step "Installing or updating Free Claude Code"
Install-FreeClaudeCode
Write-Step "Configuring PATH and verifying Free Claude Code"
Configure-AndConfirmFreeClaudeCode
Write-Host ""
if ($DryRun) {
Write-Host "Dry run complete. No changes were made."
}
else {
Write-Host "Free Claude Code is installed and verified. Start the proxy with: fcc-server"
Write-Host "Run Claude Code with: fcc-claude"
Write-Host "Run Codex with: fcc-codex"
Write-Host "Run Pi with: fcc-pi"
}
+499
View File
@@ -0,0 +1,499 @@
#!/bin/sh
set -eu
REPO_ARCHIVE_URL="https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"
PYTHON_VERSION="3.14.0"
MIN_UV_VERSION="0.11.0"
CLAUDE_INSTALL_URL="https://claude.ai/install.sh"
CODEX_INSTALL_URL="https://chatgpt.com/codex/install.sh"
PI_INSTALL_URL="https://pi.dev/install.sh"
UV_INSTALL_URL="https://astral.sh/uv/install.sh"
dry_run=0
voice_nim=0
voice_local=0
voice_all=0
torch_backend=""
temporary_script=""
show_usage() {
cat <<'USAGE'
Usage: install.sh [options]
Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code.
Options:
--voice-nim Install NVIDIA NIM voice transcription support.
--voice-local Install local Whisper voice transcription support.
--voice-all Install all voice transcription backends.
--torch-backend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice.
--dry-run Print commands without running them.
--help Show this help text.
USAGE
}
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
step() {
printf '\n==> %s\n' "$1"
}
quote_arg() {
case "$1" in
*[!A-Za-z0-9_./:@%+=,-]*|"")
escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '"%s"' "$escaped"
;;
*)
printf '%s' "$1"
;;
esac
}
print_command() {
printf '+'
for arg in "$@"; do
printf ' '
quote_arg "$arg"
done
printf '\n'
}
run() {
print_command "$@"
if [ "$dry_run" -eq 1 ]; then
return 0
fi
if "$@"; then
return 0
else
status=$?
fi
fail "Command failed with exit code $status: $1"
}
cleanup() {
if [ -n "$temporary_script" ] && [ -e "$temporary_script" ]; then
rm -f "$temporary_script"
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' HUP TERM
add_path_entry() {
[ -n "$1" ] || return 0
case ":$PATH:" in
*":$1:"*) ;;
*) PATH="$1:$PATH" ;;
esac
}
add_known_bin_directories() {
if [ -n "${XDG_BIN_HOME:-}" ]; then
add_path_entry "$XDG_BIN_HOME"
fi
if [ -n "${HOME:-}" ]; then
add_path_entry "$HOME/.local/bin"
add_path_entry "$HOME/.cargo/bin"
add_path_entry "${XDG_DATA_HOME:-$HOME/.local/share}/pi-node/current/bin"
fi
export PATH
hash -r 2>/dev/null || true
}
add_pi_bin_directories() {
[ "$dry_run" -eq 0 ] || return 0
add_known_bin_directories
if command -v npm >/dev/null 2>&1; then
pi_npm_prefix=$(npm prefix -g 2>/dev/null || npm config get prefix 2>/dev/null || true)
if [ -n "$pi_npm_prefix" ]; then
add_path_entry "$pi_npm_prefix/bin"
export PATH
hash -r 2>/dev/null || true
fi
fi
}
require_command() {
if [ "$dry_run" -eq 0 ] && ! command -v "$1" >/dev/null 2>&1; then
fail "$1 is required. Install it first, then rerun this installer."
fi
}
download_and_run() {
url=$1
interpreter=$2
label=$3
non_interactive=${4:-0}
if [ "$dry_run" -eq 1 ]; then
print_command curl -fsSL "$url" -o "<temporary-script>"
if [ "$non_interactive" -eq 1 ]; then
printf '+ CODEX_NON_INTERACTIVE=1 '
quote_arg "$interpreter"
printf ' <temporary-script>\n'
else
print_command "$interpreter" "<temporary-script>"
fi
return 0
fi
temporary_script=$(mktemp "${TMPDIR:-/tmp}/fcc-install.XXXXXX") || fail "Unable to create a temporary file for $label."
print_command curl -fsSL "$url" -o "$temporary_script"
if curl -fsSL "$url" -o "$temporary_script"; then
:
else
status=$?
fail "Could not download the $label installer (curl exit code $status)."
fi
if [ ! -s "$temporary_script" ]; then
fail "The downloaded $label installer was empty."
fi
if [ "$non_interactive" -eq 1 ]; then
printf '+ CODEX_NON_INTERACTIVE=1 '
quote_arg "$interpreter"
printf ' '
quote_arg "$temporary_script"
printf '\n'
if CODEX_NON_INTERACTIVE=1 "$interpreter" "$temporary_script"; then
:
else
status=$?
fail "$label installation failed with exit code $status."
fi
else
print_command "$interpreter" "$temporary_script"
if "$interpreter" "$temporary_script"; then
:
else
status=$?
fail "$label installation failed with exit code $status."
fi
fi
rm -f "$temporary_script"
temporary_script=""
}
verify_command() {
command_name=$1
display_name=$2
if [ "$dry_run" -eq 1 ]; then
print_command "$command_name" --version
return 0
fi
command_path=$(command -v "$command_name" 2>/dev/null) || fail "$display_name was installed, but '$command_name' is not available on PATH."
run "$command_path" --version
}
pi_command_is_compatible() {
pi_command_path=$(command -v pi 2>/dev/null) || return 1
pi_help=$("$pi_command_path" --help 2>/dev/null) || return 1
case "$pi_help" in
*--extension*) ;;
*) return 1 ;;
esac
case "$pi_help" in
*--models*) return 0 ;;
*) return 1 ;;
esac
}
verify_pi_command() {
if [ "$dry_run" -eq 1 ]; then
printf '+ pi --help (verify --extension and --models support)\n'
print_command pi --version
return 0
fi
pi_command_path=$(command -v pi 2>/dev/null) || fail "Pi was installed, but 'pi' is not available on PATH."
pi_command_is_compatible || fail "The 'pi' command at $pi_command_path is not a compatible Pi Coding Agent."
run "$pi_command_path" --version
}
ensure_claude() {
if command -v claude >/dev/null 2>&1; then
printf 'Claude Code already found on PATH; verifying it.\n'
else
download_and_run "$CLAUDE_INSTALL_URL" bash "Claude Code"
add_known_bin_directories
fi
verify_command claude "Claude Code"
}
ensure_codex() {
if command -v codex >/dev/null 2>&1; then
printf 'Codex already found on PATH; verifying it.\n'
else
download_and_run "$CODEX_INSTALL_URL" sh "Codex" 1
add_known_bin_directories
fi
verify_command codex "Codex"
}
ensure_pi() {
if [ "$dry_run" -eq 1 ] && command -v pi >/dev/null 2>&1; then
printf 'Pi already found on PATH; verifying it.\n'
elif pi_command_is_compatible; then
printf 'Pi already found on PATH; verifying it.\n'
else
if existing_pi_path=$(command -v pi 2>/dev/null); then
printf "The existing 'pi' command at %s is not Pi Coding Agent; installing Pi.\n" "$existing_pi_path"
fi
download_and_run "$PI_INSTALL_URL" sh "Pi"
add_pi_bin_directories
fi
verify_pi_command
}
current_uv_version() {
if output=$(uv --version); then
:
else
return 1
fi
case "$output" in
uv\ *) version=${output#uv } ;;
*) version=$output ;;
esac
version=${version%% *}
case "$version" in
[0-9]*.[0-9]*.[0-9]*) printf '%s\n' "$version" ;;
*) return 1 ;;
esac
}
version_ge() {
current=${1%%[-+]*}
minimum=${2%%[-+]*}
old_ifs=$IFS
IFS=.
set -- $current
current_major=${1:-0}
current_minor=${2:-0}
current_patch=${3:-0}
set -- $minimum
minimum_major=${1:-0}
minimum_minor=${2:-0}
minimum_patch=${3:-0}
IFS=$old_ifs
case "$current_major$current_minor$current_patch$minimum_major$minimum_minor$minimum_patch" in
*[!0-9]*) return 1 ;;
esac
[ "$current_major" -gt "$minimum_major" ] && return 0
[ "$current_major" -lt "$minimum_major" ] && return 1
[ "$current_minor" -gt "$minimum_minor" ] && return 0
[ "$current_minor" -lt "$minimum_minor" ] && return 1
[ "$current_patch" -ge "$minimum_patch" ]
}
verify_uv() {
if [ "$dry_run" -eq 1 ]; then
print_command uv --version
return 0
fi
command -v uv >/dev/null 2>&1 || fail "uv was installed, but it is not available on PATH."
version=$(current_uv_version) || fail "uv is present, but 'uv --version' did not return a valid version."
if ! version_ge "$version" "$MIN_UV_VERSION"; then
fail "uv $MIN_UV_VERSION or newer is required; found uv $version after installation."
fi
printf 'Verified uv %s.\n' "$version"
}
ensure_uv() {
if [ "$dry_run" -eq 1 ]; then
if command -v uv >/dev/null 2>&1; then
print_command uv --version
printf 'A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer.\n'
else
printf 'uv is not installed; the current standalone uv would be installed.\n'
download_and_run "$UV_INSTALL_URL" sh "uv"
verify_uv
fi
return 0
fi
if command -v uv >/dev/null 2>&1; then
version=$(current_uv_version) || fail "uv is present, but 'uv --version' did not return a valid version."
if version_ge "$version" "$MIN_UV_VERSION"; then
printf 'uv %s already satisfies >=%s; leaving it unchanged.\n' "$version" "$MIN_UV_VERSION"
return 0
fi
printf 'uv %s is below %s; installing the current standalone uv.\n' "$version" "$MIN_UV_VERSION"
else
printf 'uv is not installed; installing the current standalone uv.\n'
fi
download_and_run "$UV_INSTALL_URL" sh "uv"
add_known_bin_directories
verify_uv
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--voice-nim)
voice_nim=1
;;
--voice-local)
voice_local=1
;;
--voice-all)
voice_all=1
;;
--torch-backend)
shift
[ "$#" -gt 0 ] || fail "--torch-backend requires a value."
torch_backend=$1
[ -n "$torch_backend" ] || fail "--torch-backend requires a non-empty value."
;;
--torch-backend=*)
torch_backend=${1#*=}
[ -n "$torch_backend" ] || fail "--torch-backend requires a non-empty value."
;;
--dry-run)
dry_run=1
;;
--help|-h)
show_usage
exit 0
;;
*)
show_usage >&2
fail "unknown option: $1"
;;
esac
shift
done
}
validate_args() {
include_local=$voice_local
if [ "$voice_all" -eq 1 ]; then
include_local=1
fi
if [ -n "$torch_backend" ] && [ "$include_local" -ne 1 ]; then
fail "--torch-backend requires --voice-local or --voice-all."
fi
}
package_spec() {
include_nim=$voice_nim
include_local=$voice_local
if [ "$voice_all" -eq 1 ]; then
include_nim=1
include_local=1
fi
if [ "$include_nim" -eq 1 ] && [ "$include_local" -eq 1 ]; then
printf 'free-claude-code[voice,voice_local] @ %s' "$REPO_ARCHIVE_URL"
elif [ "$include_nim" -eq 1 ]; then
printf 'free-claude-code[voice] @ %s' "$REPO_ARCHIVE_URL"
elif [ "$include_local" -eq 1 ]; then
printf 'free-claude-code[voice_local] @ %s' "$REPO_ARCHIVE_URL"
else
printf 'free-claude-code @ %s' "$REPO_ARCHIVE_URL"
fi
}
install_free_claude_code() {
spec=$(package_spec)
if [ -n "$torch_backend" ]; then
run uv tool install --force --refresh-package free-claude-code --python "$PYTHON_VERSION" --torch-backend "$torch_backend" "$spec"
else
run uv tool install --force --refresh-package free-claude-code --python "$PYTHON_VERSION" "$spec"
fi
}
configure_and_verify_free_claude_code() {
run uv tool update-shell
if [ "$dry_run" -eq 1 ]; then
print_command uv tool dir --bin
printf '+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory\n'
print_command fcc-server --version
return 0
fi
print_command uv tool dir --bin
if tool_bin=$(uv tool dir --bin); then
:
else
status=$?
fail "Could not determine the uv tool bin directory (exit code $status)."
fi
[ -n "$tool_bin" ] || fail "uv returned an empty tool bin directory."
add_path_entry "$tool_bin"
export PATH
hash -r 2>/dev/null || true
for command_name in fcc-server fcc-claude fcc-codex fcc-pi; do
[ -x "$tool_bin/$command_name" ] || fail "Free Claude Code installation did not create $tool_bin/$command_name."
done
run "$tool_bin/fcc-server" --version
}
parse_args "$@"
validate_args
add_known_bin_directories
step "Checking installation prerequisites"
require_command curl
require_command bash
require_command sh
require_command mktemp
step "Ensuring Claude Code is installed"
ensure_claude
step "Ensuring Codex is installed"
ensure_codex
step "Ensuring Pi is installed"
ensure_pi
step "Ensuring uv $MIN_UV_VERSION or newer is installed"
ensure_uv
step "Installing or updating Free Claude Code"
install_free_claude_code
step "Configuring PATH and verifying Free Claude Code"
configure_and_verify_free_claude_code
if [ "$dry_run" -eq 1 ]; then
printf '\nDry run complete. No changes were made.\n'
else
printf '\nFree Claude Code is installed and verified. Start the proxy with: fcc-server\n'
printf 'Run Claude Code with: fcc-claude\n'
printf 'Run Codex with: fcc-codex\n'
printf 'Run Pi with: fcc-pi\n'
fi
+271
View File
@@ -0,0 +1,271 @@
param(
[switch] $DryRun,
[switch] $Help,
[Parameter(ValueFromRemainingArguments = $true)]
[object[]] $RemainingArgs = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$PackageName = "free-claude-code"
$FccHomeDirname = ".fcc"
$FccCommands = @(
"fcc-server",
"fcc-claude",
"fcc-codex",
"fcc-pi",
"fcc-init",
"free-claude-code"
)
$script:UvPath = ""
$script:UvToolBin = ""
function Show-Usage {
@"
Usage: uninstall.ps1 [options]
Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified.
Does not remove uv, Claude Code, Codex, Pi, the uv-managed Python runtime, or shared PATH entries.
Options:
-DryRun Print commands without running them.
-Help Show this help text.
"@
}
function Write-Step {
param([string] $Message)
Write-Host ""
Write-Host "==> $Message"
}
function Format-Argument {
param([string] $Value)
if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') {
return $Value
}
return "'" + ($Value -replace "'", "''") + "'"
}
function Format-Command {
param(
[string] $FilePath,
[string[]] $Arguments = @()
)
$parts = @($FilePath) + $Arguments
return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " "
}
function Get-ApplicationCommand {
param([string] $Name)
$commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue)
if ($commands.Count -eq 0) {
return $null
}
return $commands[0]
}
function Invoke-NativeResult {
param(
[string] $FilePath,
[string[]] $Arguments
)
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$global:LASTEXITCODE = 0
$output = (& $FilePath @Arguments 2>&1 | Out-String).Trim()
return [pscustomobject] @{
ExitCode = $LASTEXITCODE
Output = $output
}
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
}
}
function Test-MissingUvToolError {
param([string] $Output)
$normalized = $Output.ToLowerInvariant()
return $normalized.Contains($PackageName) -and $normalized.Contains("is not installed")
}
function Add-PathEntry {
param([string] $PathEntry)
if ([string]::IsNullOrWhiteSpace($PathEntry)) {
return
}
$separator = [IO.Path]::PathSeparator
$entries = @()
if (-not [string]::IsNullOrEmpty($env:Path)) {
$entries = $env:Path -split [regex]::Escape([string] $separator)
}
if ($entries -notcontains $PathEntry) {
$env:Path = "$PathEntry$separator$env:Path"
}
}
function Add-KnownUvPaths {
Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin")
Add-PathEntry (Join-Path $env:USERPROFILE ".cargo\bin")
}
function Assert-NoFccProcessesRunning {
$running = @()
foreach ($commandName in $FccCommands) {
$processes = @(Get-Process -Name $commandName -ErrorAction SilentlyContinue)
if ($processes.Count -gt 0) {
$running += $commandName
}
}
if ($running.Count -gt 0) {
throw "Free Claude Code is still running ($($running -join ', ')). Stop those processes, then rerun uninstall."
}
}
function Initialize-UvContext {
Add-KnownUvPaths
if ($DryRun) {
Write-Host "+ uv tool dir --bin"
return
}
$uvCommand = Get-ApplicationCommand "uv"
if (-not $uvCommand) {
throw "uv is required to remove the Free Claude Code tool. Install uv, then rerun this uninstaller; ~/.fcc was not deleted."
}
$script:UvPath = $uvCommand.Source
$commandText = Format-Command -FilePath $script:UvPath -Arguments @("tool", "dir", "--bin")
Write-Host "+ $commandText"
$result = Invoke-NativeResult -FilePath $script:UvPath -Arguments @("tool", "dir", "--bin")
if ($result.ExitCode -ne 0) {
if (-not [string]::IsNullOrWhiteSpace($result.Output)) {
[Console]::Error.WriteLine($result.Output)
}
throw "Could not determine the uv tool bin directory (exit code $($result.ExitCode)); ~/.fcc was not deleted."
}
$script:UvToolBin = $result.Output.Trim()
if ([string]::IsNullOrWhiteSpace($script:UvToolBin)) {
throw "uv returned an empty tool bin directory; ~/.fcc was not deleted."
}
}
function Uninstall-FreeClaudeCode {
Write-Host "+ uv tool uninstall $PackageName"
if ($DryRun) {
return
}
$result = Invoke-NativeResult -FilePath $script:UvPath -Arguments @(
"tool",
"uninstall",
$PackageName
)
if ($result.ExitCode -eq 0) {
if (-not [string]::IsNullOrWhiteSpace($result.Output)) {
Write-Host $result.Output
}
return
}
if (Test-MissingUvToolError -Output $result.Output) {
Write-Host "Free Claude Code uv tool is already absent; verifying its entry points."
return
}
if (-not [string]::IsNullOrWhiteSpace($result.Output)) {
[Console]::Error.WriteLine($result.Output)
}
throw "uv tool uninstall $PackageName failed with exit code $($result.ExitCode); ~/.fcc was not deleted."
}
function Confirm-FccCommandsRemoved {
if ($DryRun) {
Write-Host "+ verify all Free Claude Code entry points are absent from the uv tool bin directory"
return
}
$remaining = @()
$extensions = @("", ".exe", ".cmd", ".bat", ".ps1")
foreach ($commandName in $FccCommands) {
foreach ($extension in $extensions) {
$commandPath = Join-Path $script:UvToolBin "$commandName$extension"
if (Test-Path -LiteralPath $commandPath) {
$remaining += $commandPath
}
}
}
if ($remaining.Count -gt 0) {
throw "Free Claude Code entry points remain after uv uninstall: $($remaining -join ', '); ~/.fcc was not deleted."
}
}
function Purge-FccHome {
$fccHome = Join-Path $env:USERPROFILE $FccHomeDirname
if (-not (Test-Path -LiteralPath $fccHome)) {
Write-Host "No FCC config directory at $fccHome; skipping purge."
return
}
$commandText = @(
"Remove-Item",
"-LiteralPath",
(Format-Argument $fccHome),
"-Recurse",
"-Force"
) -join " "
Write-Host "+ $commandText"
if ($DryRun) {
return
}
Remove-Item -LiteralPath $fccHome -Recurse -Force
if (Test-Path -LiteralPath $fccHome) {
throw "FCC config directory still exists after deletion: $fccHome"
}
}
if ($Help) {
Show-Usage
return
}
if ($RemainingArgs.Count -gt 0) {
Show-Usage
throw "Unknown option: $($RemainingArgs -join ' ')"
}
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
throw "USERPROFILE is not set; cannot locate Free Claude Code data."
}
Write-Step "Checking for running Free Claude Code processes"
Assert-NoFccProcessesRunning
Write-Step "Locating the uv-managed Free Claude Code installation"
Initialize-UvContext
Write-Step "Removing the Free Claude Code uv tool"
Uninstall-FreeClaudeCode
Write-Step "Verifying Free Claude Code entry points were removed"
Confirm-FccCommandsRemoved
Write-Step "Purging FCC config and data from ~/.fcc"
Purge-FccHome
Write-Host ""
if ($DryRun) {
Write-Host "Dry run complete. No changes were made."
}
else {
Write-Host "Free Claude Code has been removed and verified."
Write-Host "uv, Claude Code, Codex, Pi, the uv-managed Python runtime, and shared PATH entries were left installed."
}
+243
View File
@@ -0,0 +1,243 @@
#!/bin/sh
set -eu
PACKAGE_NAME="free-claude-code"
FCC_HOME_DIRNAME=".fcc"
FCC_COMMANDS="fcc-server fcc-claude fcc-codex fcc-pi fcc-init free-claude-code"
dry_run=0
uv_tool_bin=""
show_usage() {
cat <<'USAGE'
Usage: uninstall.sh [options]
Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified.
Does not remove uv, Claude Code, Codex, Pi, the uv-managed Python runtime, or shared PATH entries.
Options:
--dry-run Print commands without running them.
--help Show this help text.
USAGE
}
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
step() {
printf '\n==> %s\n' "$1"
}
quote_arg() {
case "$1" in
*[!A-Za-z0-9_./:@%+=,-]*|"")
escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '"%s"' "$escaped"
;;
*)
printf '%s' "$1"
;;
esac
}
print_command() {
printf '+'
for arg in "$@"; do
printf ' '
quote_arg "$arg"
done
printf '\n'
}
run() {
print_command "$@"
if [ "$dry_run" -eq 1 ]; then
return 0
fi
if "$@"; then
return 0
else
status=$?
fi
fail "Command failed with exit code $status: $1"
}
is_missing_uv_tool_error() {
normalized=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
case "$normalized" in
*"$PACKAGE_NAME"*"is not installed"*) return 0 ;;
*) return 1 ;;
esac
}
add_path_entry() {
[ -n "$1" ] || return 0
case ":$PATH:" in
*":$1:"*) ;;
*) PATH="$1:$PATH" ;;
esac
}
add_known_uv_paths() {
if [ -n "${XDG_BIN_HOME:-}" ]; then
add_path_entry "$XDG_BIN_HOME"
fi
add_path_entry "$HOME/.local/bin"
add_path_entry "$HOME/.cargo/bin"
export PATH
hash -r 2>/dev/null || true
}
is_fcc_command_running() {
command_name=$1
if command -v pgrep >/dev/null 2>&1; then
if pgrep -x "$command_name" >/dev/null 2>&1; then
return 0
fi
if pgrep -f "(^|/)${command_name}( |$)" >/dev/null 2>&1; then
return 0
fi
return 1
fi
ps -A -o comm= 2>/dev/null | grep -qx "$command_name"
}
assert_no_fcc_processes_running() {
running=""
for command_name in $FCC_COMMANDS; do
if is_fcc_command_running "$command_name"; then
running="${running} ${command_name}"
fi
done
if [ -n "$running" ]; then
fail "Free Claude Code is still running (${running# }). Stop those processes, then rerun uninstall."
fi
}
initialize_uv_context() {
add_known_uv_paths
if [ "$dry_run" -eq 1 ]; then
print_command uv tool dir --bin
return 0
fi
if ! command -v uv >/dev/null 2>&1; then
fail "uv is required to remove the Free Claude Code tool. Install uv, then rerun this uninstaller; ~/.fcc was not deleted."
fi
print_command uv tool dir --bin
if uv_tool_bin=$(uv tool dir --bin); then
:
else
status=$?
fail "Could not determine the uv tool bin directory (exit code $status); ~/.fcc was not deleted."
fi
[ -n "$uv_tool_bin" ] || fail "uv returned an empty tool bin directory; ~/.fcc was not deleted."
}
uninstall_free_claude_code() {
print_command uv tool uninstall "$PACKAGE_NAME"
if [ "$dry_run" -eq 1 ]; then
return 0
fi
if output=$(uv tool uninstall "$PACKAGE_NAME" 2>&1); then
if [ -n "$output" ]; then
printf '%s\n' "$output"
fi
return 0
else
status=$?
fi
if is_missing_uv_tool_error "$output"; then
printf 'Free Claude Code uv tool is already absent; verifying its entry points.\n'
return 0
fi
if [ -n "$output" ]; then
printf '%s\n' "$output" >&2
fi
fail "uv tool uninstall $PACKAGE_NAME failed with exit code $status; ~/.fcc was not deleted."
}
verify_fcc_commands_removed() {
if [ "$dry_run" -eq 1 ]; then
printf '+ verify all Free Claude Code entry points are absent from the uv tool bin directory\n'
return 0
fi
remaining=""
for command_name in $FCC_COMMANDS; do
command_path="$uv_tool_bin/$command_name"
if [ -e "$command_path" ] || [ -L "$command_path" ]; then
remaining="${remaining} ${command_path}"
fi
done
if [ -n "$remaining" ]; then
fail "Free Claude Code entry points remain after uv uninstall:${remaining}; ~/.fcc was not deleted."
fi
}
purge_fcc_home() {
fcc_home="$HOME/$FCC_HOME_DIRNAME"
if [ ! -e "$fcc_home" ]; then
printf 'No FCC config directory at %s; skipping purge.\n' "$fcc_home"
return 0
fi
run rm -rf "$fcc_home"
if [ "$dry_run" -eq 0 ] && [ -e "$fcc_home" ]; then
fail "FCC config directory still exists after deletion: $fcc_home"
fi
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
dry_run=1
;;
--help|-h)
show_usage
exit 0
;;
*)
show_usage >&2
fail "unknown option: $1"
;;
esac
shift
done
}
parse_args "$@"
[ -n "${HOME:-}" ] || fail "HOME is not set; cannot locate Free Claude Code data."
step "Checking for running Free Claude Code processes"
assert_no_fcc_processes_running
step "Locating the uv-managed Free Claude Code installation"
initialize_uv_context
step "Removing the Free Claude Code uv tool"
uninstall_free_claude_code
step "Verifying Free Claude Code entry points were removed"
verify_fcc_commands_removed
step "Purging FCC config and data from ~/.fcc"
purge_fcc_home
if [ "$dry_run" -eq 1 ]; then
printf '\nDry run complete. No changes were made.\n'
else
printf '\nFree Claude Code has been removed and verified.\n'
printf 'uv, Claude Code, Codex, Pi, the uv-managed Python runtime, and shared PATH entries were left installed.\n'
fi
+184
View File
@@ -0,0 +1,184 @@
# Product E2E Smoke Tests
`smoke/` is local-only. It can launch subprocesses, call real providers, touch
local model servers, and optionally send/delete bot messages. Hermetic contracts
belong under `tests/` and must stay green with plain `uv run pytest`.
## Taxonomy
- `smoke/prereq/`: liveness checks that prove the server, routes, auth, CLI
scripts, provider pings, local `/models`, and bot permissions are reachable.
These are prerequisites only.
- `smoke/product/`: end-to-end product scenarios. Feature smoke coverage comes
from these tests, not from route/header/provider pings.
- `smoke/features.py`: source-of-truth feature map:
feature -> subfeature -> scenario -> env -> expected behavior -> failure class.
## Required Local Commands
```powershell
uv run pytest smoke --collect-only -q
uv run pytest smoke -n 0 -s --tb=short
```
The second command skips everything unless `FCC_LIVE_SMOKE=1` is set, but still
writes skip entries to `.smoke-results/`.
## Product Smoke Run
```powershell
$env:FCC_LIVE_SMOKE = "1"
uv run pytest smoke -n 0 -s --tb=short
```
Provider smoke scenarios can run providers in parallel while preserving
sequential execution within each provider:
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_TARGETS = "providers"
uv run pytest smoke -n auto --dist=loadgroup -s --tb=short
```
Provider product E2E runs once per configured provider, independent of `MODEL`,
`MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU`. Defaults come from the provider
catalog/docs and can be overridden with `FCC_SMOKE_MODEL_<PROVIDER>`, for example
`FCC_SMOKE_MODEL_DEEPSEEK=deepseek-v4-pro` (or `deepseek-v4-flash`). If no provider smoke model is
configured, live product smoke fails as `missing_env` unless you explicitly set
`FCC_ALLOW_NO_PROVIDER_SMOKE=1`.
## Targets
Default targets do not send real bot messages or load voice backends:
| Target | Product scenarios | Required environment |
| --- | --- | --- |
| `api` | messages, count_tokens full payload, errors, `/stop`, optimizations | configured provider only for streaming messages |
| `auth` | x-api-key, bearer, anthropic-auth-token, invalid/missing auth | none; test sets an isolated token |
| `cli` | `fcc-init`, server entrypoint, Claude CLI adaptive thinking, session cleanup | Claude CLI binary and provider only for real CLI |
| `clients` | VS Code and JetBrains protocol payloads | configured provider |
| `config` | env precedence, removed-env migration, proxy/timeouts | none |
| `extensibility` | provider runtime and platform factory construction | none |
| `messaging` | fake Discord/Telegram full flow, literal clear scopes, trees, persistence, voice cancel | none |
| `providers` | multi-turn text, adaptive thinking history, tools, disconnect, errors | configured providers, optional `FCC_SMOKE_MODEL_*` |
| `tools` | forced tool_use and tool_result continuation | tool-capable configured provider |
| `rate_limit` | disconnect cleanup and follow-up request | configured provider |
| `lmstudio` | local `/models` plus OpenAI-chat-backed Messages through proxy | running LM Studio server |
| `llamacpp` | local `/models` plus OpenAI-chat-backed Messages through proxy | running llama-server |
| `ollama` | local `/v1/models` plus OpenAI-chat-backed Messages through proxy | running Ollama server |
Heavy/side-effectful targets are opt-in:
| Target | Product scenarios | Required environment |
| --- | --- | --- |
| `nvidia_nim_cli` | Claude Code CLI feature matrix across NIM models | `NVIDIA_NIM_API_KEY`, Claude CLI |
| `openrouter_free_cli` | Claude Code CLI feature matrix across OpenRouter free models | `OPENROUTER_API_KEY`, Claude CLI |
| `telegram` | getMe, send, edit, delete, optional manual inbound | token and chat/user ID |
| `discord` | channel access, send, edit, delete, optional manual inbound | token and channel ID |
| `voice` | generated WAV through local Whisper or NVIDIA NIM transcription | `VOICE_NOTE_ENABLED=true`, `FCC_SMOKE_RUN_VOICE=1` |
## Examples
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_PROVIDER_MATRIX = "open_router,nvidia_nim,deepseek,lmstudio,llamacpp,ollama"
uv run pytest smoke/product -n 0 -s --tb=short
```
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_TARGETS = "ollama"
$env:OLLAMA_BASE_URL = "http://localhost:11434"
uv run pytest smoke/prereq smoke/product -n 0 -s --tb=short
```
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_TARGETS = "telegram,discord,voice"
$env:FCC_SMOKE_RUN_VOICE = "1"
uv run pytest smoke/product -n 0 -s --tb=short
```
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_TARGETS = "nvidia_nim_cli"
$env:FCC_SMOKE_NIM_MODELS = "z-ai/glm-5.2,moonshotai/kimi-k2.6,minimaxai/minimax-m2.7,nvidia/nemotron-3-super-120b-a12b,deepseek-ai/deepseek-v4-pro,deepseek-ai/deepseek-v4-flash"
uv run pytest smoke/product -n 0 -s --tb=short
```
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_TARGETS = "openrouter_free_cli"
$env:FCC_SMOKE_OPENROUTER_FREE_MODELS = "nvidia/nemotron-3-super-120b-a12b:free,openai/gpt-oss-120b:free,poolside/laguna-m.1:free"
uv run pytest smoke/product -n 0 -s --tb=short
```
```powershell
$env:FCC_LIVE_SMOKE = "1"
$env:FCC_SMOKE_TARGETS = "messaging,config,extensibility"
uv run pytest smoke/product -n 0 -s --tb=short
```
## Environment
- `FCC_ENV_FILE`: explicit dotenv path for startup/config scenarios.
- `FCC_LIVE_SMOKE=1`: enables live smoke execution.
- `FCC_ALLOW_NO_PROVIDER_SMOKE=1`: permits no-provider live smoke for harness work.
- `FCC_SMOKE_TARGETS`: comma-separated targets, or `all`.
- `FCC_SMOKE_PROVIDER_MATRIX`: comma-separated provider prefixes to require.
- `FCC_SMOKE_MODEL_NVIDIA_NIM`, `FCC_SMOKE_MODEL_OPEN_ROUTER`,
`FCC_SMOKE_MODEL_MISTRAL`, `FCC_SMOKE_MODEL_MISTRAL_REASONING`,
`FCC_SMOKE_MODEL_MISTRAL_CODESTRAL`,
`FCC_SMOKE_MODEL_DEEPSEEK`, `FCC_SMOKE_MODEL_KIMI`,
`FCC_SMOKE_MODEL_WAFER`, `FCC_SMOKE_MODEL_MINIMAX`,
`FCC_SMOKE_MODEL_OPENCODE`, `FCC_SMOKE_MODEL_OPENCODE_GO`,
`FCC_SMOKE_MODEL_ZAI`, `FCC_SMOKE_MODEL_FIREWORKS`, `FCC_SMOKE_MODEL_CLOUDFLARE`,
`FCC_SMOKE_MODEL_GEMINI`, `FCC_SMOKE_MODEL_GROQ`, `FCC_SMOKE_MODEL_CEREBRAS`,
`FCC_SMOKE_MODEL_LMSTUDIO`,
`FCC_SMOKE_MODEL_LLAMACPP`, `FCC_SMOKE_MODEL_OLLAMA`: optional per-provider
smoke model overrides. Values may include the provider prefix or just the model
name for that provider.
- `FCC_SMOKE_MODEL_MISTRAL_REASONING`: optional override for the dedicated
Mistral native reasoning smoke, default `mistral/mistral-medium-3-5`.
- `FCC_SMOKE_NIM_MODELS`: optional comma-separated NVIDIA NIM CLI matrix models
that replace the default characterization set.
- `FCC_SMOKE_NIM_EXTRA_MODELS`: optional comma-separated NVIDIA NIM CLI matrix
models appended to the default or replacement set.
- `FCC_SMOKE_OPENROUTER_FREE_MODELS`: optional comma-separated OpenRouter free
CLI matrix models that replace the default characterization set.
- `FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS`: optional comma-separated OpenRouter
free CLI matrix models appended to the default or replacement set.
- `FCC_SMOKE_TIMEOUT_S`: per-request/subprocess timeout, default `45`.
- `FCC_SMOKE_CLAUDE_BIN`: Claude CLI executable name, default `claude`.
- `FCC_SMOKE_TELEGRAM_CHAT_ID`: Telegram chat/user ID for send/edit/delete.
- `FCC_SMOKE_DISCORD_CHANNEL_ID`: Discord channel ID for send/edit/delete.
- `FCC_SMOKE_INTERACTIVE=1`: enables manual inbound Telegram/Discord checks.
- `FCC_SMOKE_RUN_VOICE=1`: allows voice transcription backends to load/run.
## Windows / nested `uv run`
Run smoke the same way you run tests (`uv run pytest smoke` from the repo). Child
processes use the **same Python interpreter** as the test runner, not nested
`uv run`, so Windows does not try to replace `free-claude-code.exe` while it is
locked.
## Failure Classes
Smoke artifacts are written to `.smoke-results/` and redact env values whose
names contain `KEY`, `TOKEN`, `SECRET`, `WEBHOOK`, or `AUTH`.
- `missing_env`: required credentials, binary, provider config, local provider
server/model, or opt-in flag is absent.
- `upstream_unavailable`: a real provider or bot API is not reachable.
- `probe_timeout`: the smoke driver reached the target, but the CLI/probe did
not complete within the smoke timeout.
- `product_failure`: the app accepted the scenario but returned the wrong shape,
crashed, leaked state, or violated the product contract.
- `harness_bug`: the smoke test or driver made an invalid assumption.
- `target_disabled`: skipped because `FCC_SMOKE_TARGETS` intentionally selected
a different target.
`product_failure` and `harness_bug` are failures. `missing_env`,
`upstream_unavailable`, and `probe_timeout` are skips except when the user
explicitly selected a provider in `FCC_SMOKE_PROVIDER_MATRIX`;
selected-but-missing providers fail.
+1
View File
@@ -0,0 +1 @@
"""Local-only live smoke tests for free-claude-code."""
+524
View File
@@ -0,0 +1,524 @@
"""Hierarchical public capability map.
This module is the architectural companion to ``smoke.features``. The feature
inventory says which public features must be covered; this map records the
subfeature contracts, owning module boundary, and coverage owners that protect
them while the internals are refactored.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class CapabilityContract:
capability: str
subfeature: str
feature_id: str
owner: str
inputs: str
outputs: str
failure: str
pytest_tests: tuple[str, ...]
smoke_tests: tuple[str, ...] = ()
CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
CapabilityContract(
"api_compatibility",
"routes_and_probes",
"anthropic_api_routes",
"free_claude_code.api.routes",
"Anthropic-compatible HTTP requests",
"JSON or Anthropic SSE responses",
"HTTPException or Anthropic error payload",
("tests/api/test_api.py",),
("test_probe_and_models_routes", "test_stop_endpoint_reports_no_messaging"),
),
CapabilityContract(
"api_compatibility",
"head_options_probes",
"probe_routes",
"free_claude_code.api.routes",
"HEAD/OPTIONS probe requests",
"204 with Allow header",
"auth failure when configured",
("tests/api/test_api.py::test_probe_endpoints_return_204_with_allow_headers",),
("test_probe_and_models_routes",),
),
CapabilityContract(
"api_compatibility",
"client_request_shapes",
"drop_in_claude_code_replacement",
"free_claude_code.api.handlers.messages.MessagesHandler",
"Claude Code, VS Code, and JetBrains shaped requests",
"compatible streaming response",
"provider or validation error in Anthropic shape",
("tests/api/test_api.py", "tests/cli/test_cli.py"),
("test_vscode_and_jetbrains_shaped_requests",),
),
CapabilityContract(
"api_compatibility",
"responses_api",
"drop_in_codex_replacement",
"free_claude_code.api.handlers.responses.ResponsesHandler",
"OpenAI Responses requests from Codex",
"Responses SSE or JSON response",
"OpenAI-shaped error or conversion error",
(
"tests/api/test_openai_responses.py",
"tests/core/openai_responses/test_sse.py",
"tests/cli/test_entrypoints.py",
"tests/cli/test_codex_model_catalog.py",
),
(
"test_probe_and_models_routes",
"test_provider_codex_responses_text_e2e",
),
),
CapabilityContract(
"api_compatibility",
"client_extensions",
"vscode_extension",
"free_claude_code.api.handlers.messages.MessagesHandler",
"VS Code beta query/header variants",
"accepted Anthropic-compatible response",
"HTTP error only for auth/provider failures",
(),
("test_vscode_and_jetbrains_shaped_requests",),
),
CapabilityContract(
"api_compatibility",
"client_extensions",
"intellij_extension",
"free_claude_code.api.handlers.messages.MessagesHandler",
"JetBrains/ACP request variants",
"accepted Anthropic-compatible response",
"HTTP error only for auth/provider failures",
(),
("test_vscode_and_jetbrains_shaped_requests",),
),
CapabilityContract(
"auth",
"anthropic_headers",
"optional_authentication",
"free_claude_code.api.dependencies.require_api_key",
"x-api-key, authorization, anthropic-auth-token",
"request accepted or 401",
"401 missing/invalid API key",
("tests/api/test_auth.py",),
("test_auth_token_is_enforced_for_all_supported_header_shapes",),
),
CapabilityContract(
"provider_routing",
"provider_runtime",
"provider_matrix",
"free_claude_code.runtime.provider_manager.ProviderRuntimeManager",
"provider id and Settings",
"configured BaseProvider instance",
"503 for missing credentials; invalid_request_error for unknown provider",
("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"),
(
"test_configured_provider_models_stream_successfully",
"test_provider_matrix_presence_e2e",
),
),
CapabilityContract(
"provider_routing",
"claude_model_resolution",
"per_model_mapping",
"free_claude_code.application.routing.ModelRouter",
"Claude model name and configured MODEL_* values",
"provider id plus provider model name",
"settings validation rejects invalid provider prefixes",
("tests/application/test_routing.py", "tests/config/test_config.py"),
("test_model_mapping_configuration_is_consistent",),
),
CapabilityContract(
"provider_routing",
"mixed_provider_resolution",
"mixed_provider_mapping",
"free_claude_code.application.routing.ModelRouter",
"Opus/Sonnet/Haiku/fallback model config",
"distinct provider selections per request",
"skip live execution when providers are not configured",
("tests/application/test_routing.py", "tests/config/test_config.py"),
("test_mixed_provider_model_mapping_when_configured",),
),
CapabilityContract(
"provider_routing",
"provider_runtime_config",
"provider_proxy_timeout_config",
"free_claude_code.providers.runtime.ProviderRuntime",
"provider proxy, timeout, and rate-limit settings",
"provider client and instance-owned limiter config",
"provider construction failure",
(
"tests/api/test_dependencies.py",
"tests/providers/test_provider_runtime.py",
"tests/providers/test_provider_rate_limit.py",
),
),
CapabilityContract(
"provider_routing",
"zero_cost_backends",
"zero_cost_provider_access",
"free_claude_code.providers.runtime.ProviderRuntime",
"configured free/local provider",
"streaming response from selected backend",
"missing env or upstream unavailable skip in smoke",
("tests/api/test_dependencies.py", "tests/providers/"),
("test_configured_provider_models_stream_successfully",),
),
CapabilityContract(
"streaming_conversion",
"anthropic_sse_lifecycle",
"streaming_error_mapping",
"free_claude_code.core.failures.ExecutionFailure",
"provider stream failures before or after the HTTP commit boundary",
"protocol-specific JSON failure or terminal stream event",
"ordinary request failures remain distinct from terminal execution",
(
"tests/api/test_execution_failure_contract.py",
"tests/api/test_ordinary_error_phases.py",
"tests/core/test_failure_protocol_mapping.py",
"tests/providers/test_execution_failure_boundary.py",
"tests/providers/test_failure_policy.py",
),
),
CapabilityContract(
"streaming_conversion",
"thinking_blocks",
"thinking_token_support",
"free_claude_code.core.anthropic.thinking",
"reasoning_content, reasoning_details, <think> text, native thinking",
"Claude thinking blocks or suppression",
"thinking hidden when disabled",
(
"tests/contracts/test_stream_contracts.py",
"tests/providers/test_converter.py",
"tests/providers/test_deepseek.py",
"tests/providers/test_nvidia_nim_request.py",
"tests/providers/test_open_router.py",
),
(
"test_per_model_thinking_config_e2e",
"test_provider_reasoning_tool_continuation_e2e",
),
),
CapabilityContract(
"streaming_conversion",
"heuristic_tools",
"heuristic_tool_parser",
"free_claude_code.core.anthropic.tools",
"textual tool-call output",
"structured Anthropic tool_use blocks",
"text fallback when malformed",
("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"),
(
"test_live_tool_use_when_configured_model_supports_tools",
"test_provider_reasoning_tool_continuation_e2e",
),
),
CapabilityContract(
"streaming_conversion",
"subagent_task_control",
"subagent_control",
"free_claude_code.core.anthropic.streaming.AnthropicStreamLedger",
"Task tool call arguments",
"run_in_background=false",
"invalid JSON flushed as safe object",
("tests/providers/test_subagent_interception.py",),
),
CapabilityContract(
"request_behavior",
"local_optimizations",
"request_optimization",
"free_claude_code.api.optimization_handlers",
"Claude Code probe/title/suggestion/filepath requests",
"local MessagesResponse without provider call",
"falls through to provider when unmatched/disabled",
(
"tests/api/test_optimization_handlers.py",
"tests/api/test_routes_optimizations.py",
),
("test_optimization_fast_paths_do_not_need_provider",),
),
CapabilityContract(
"request_behavior",
"token_counting",
"count_tokens_contract",
"free_claude_code.core.anthropic.get_token_count",
"messages, system blocks, tools, images, thinking, tool results",
"positive input token estimate",
"500 with readable detail on unexpected failure",
("tests/api/test_request_utils.py",),
("test_count_tokens_accepts_thinking_tools_and_results",),
),
CapabilityContract(
"config",
"env_precedence",
"config_env_precedence",
"free_claude_code.config.settings.Settings",
"process env, user env file, repo env file, FCC_ENV_FILE",
"deterministic settings values",
"validation error for invalid settings",
("tests/config/test_config.py",),
),
CapabilityContract(
"config",
"removed_env_migration",
"removed_env_migration",
"free_claude_code.config.settings.Settings",
"NIM_ENABLE_THINKING or ENABLE_THINKING in env or dotenv",
"startup succeeds and stale keys do not change thinking defaults",
"removed key ignored",
("tests/config/test_config.py",),
),
CapabilityContract(
"provider_runtime",
"rate_limit_and_disconnect",
"smart_rate_limiting",
"free_claude_code.providers.rate_limit.ProviderRateLimiter",
"concurrent provider requests and transient upstream/disconnect failures",
"provider-specific classification, proactive throttle, retry, cleanup",
"mapped provider error or smoke skip for upstream disconnect",
(
"tests/providers/test_provider_rate_limit.py",
"tests/providers/test_nvidia_nim_degraded_retry.py",
),
("test_client_disconnect_mid_stream_does_not_crash_server",),
),
CapabilityContract(
"provider_runtime",
"generation_hot_swap",
"provider_hot_swap",
"free_claude_code.runtime.provider_manager.ProviderRuntimeManager",
"validated Admin provider config and concurrent request streams",
"atomic generation publication with old-stream completion",
"failed candidate or persistence leaves current generation unchanged",
(
"tests/runtime/test_provider_manager.py",
"tests/api/test_response_streams.py",
"tests/api/test_admin.py",
),
("test_provider_hot_swap_preserves_inflight_stream_e2e",),
),
CapabilityContract(
"local_providers",
"lmstudio_messages",
"lmstudio_endpoint",
"free_claude_code.providers.lmstudio.LMStudioProvider",
"Anthropic Messages request and local LM Studio URL",
"Anthropic SSE converted from an OpenAI Chat upstream",
"typed provider failure for local upstream errors",
("tests/providers/test_lmstudio.py",),
("test_lmstudio_messages_e2e",),
),
CapabilityContract(
"local_providers",
"llamacpp_openai_chat",
"llamacpp_endpoint",
"free_claude_code.providers.openai_chat.OpenAIChatProvider",
"OpenAI Chat request and llama.cpp URL",
"Anthropic SSE converted from OpenAI Chat chunks",
"typed provider failure for local upstream errors",
("tests/providers/test_llamacpp.py",),
("test_llamacpp_models_endpoint_when_available",),
),
CapabilityContract(
"local_providers",
"ollama_openai_chat",
"ollama_endpoint",
"free_claude_code.providers.openai_chat.OpenAIChatProvider",
"OpenAI Chat request and local Ollama root URL",
"Anthropic SSE converted from OpenAI Chat chunks",
"typed provider failure for local upstream errors",
("tests/providers/test_ollama.py",),
("test_ollama_models_endpoint_when_available",),
),
CapabilityContract(
"openrouter",
"openai_chat_provider",
"provider_matrix",
"free_claude_code.providers.open_router.OpenRouterProvider",
"OpenAI Chat request for OpenRouter",
"OpenAI stream mapped to Anthropic SSE",
"Anthropic SSE error shape",
(
"tests/providers/test_open_router.py",
"tests/providers/test_openai_compat_5xx_retry.py",
),
("test_configured_provider_models_stream_successfully",),
),
CapabilityContract(
"messaging",
"discord_telegram_platforms",
"discord_telegram_bot",
"free_claude_code.messaging.workflow.MessagingWorkflow",
"Discord/Telegram incoming messages and API callbacks",
"live progress edits and final transcript",
"platform retry/fallback or user-facing error",
(
"tests/messaging/test_discord_platform.py",
"tests/messaging/test_telegram.py",
"tests/messaging/test_limiter.py",
"tests/messaging/test_platform_outbox.py",
"tests/runtime/test_application_runtime.py",
),
("test_telegram_bot_api_permissions", "test_discord_bot_api_permissions"),
),
CapabilityContract(
"messaging",
"commands",
"messaging_commands",
"free_claude_code.messaging.commands",
"/stop, /clear, /stats command messages",
"chat-wide cleanup or literal reply-subtree deletion",
"terminal status edit, no-op feedback, or best-effort platform cleanup",
(
"tests/messaging/test_handler.py",
"tests/messaging/test_handler_integration.py",
"tests/messaging/test_tree_ownership_concurrency.py",
),
(
"test_messaging_commands_stop_clear_stats_e2e",
"test_reply_clear_uses_literal_platform_subtree_e2e",
"test_messaging_startup_notice_is_clearable_e2e",
"test_messaging_active_stop_uses_status_only_e2e",
"test_messaging_queued_scoped_cancel_e2e",
),
),
CapabilityContract(
"messaging",
"tree_threading",
"tree_threading",
"free_claude_code.messaging.trees.TreeQueueManager",
"reply-based message graph",
"queued branches and scoped cancellation",
"node error propagation",
(
"tests/messaging/test_tree_queue.py",
"tests/messaging/test_tree_concurrency.py",
"tests/messaging/test_message_tree_transitions.py",
"tests/messaging/test_tree_ownership_concurrency.py",
),
(
"test_tree_threading_e2e",
"test_messaging_queued_scoped_cancel_e2e",
"test_same_message_ids_are_isolated_by_chat_e2e",
),
),
CapabilityContract(
"persistence",
"restart_restore",
"restart_restore",
"free_claude_code.messaging.session.SessionStore",
"stored tree JSON",
"restored scoped reply lookup",
"stale pending work marked lost",
("tests/messaging/test_restart_reply_restore.py",),
),
CapabilityContract(
"persistence",
"session_store",
"session_persistence",
"free_claude_code.messaging.session.SessionStore",
"scoped tree data and message log",
"JSON persistence compatible with existing files",
"best-effort flush error logging",
("tests/messaging/test_session_store_edge_cases.py",),
),
CapabilityContract(
"voice",
"voice_transcription",
"voice_notes",
"free_claude_code.messaging.voice.Transcriber",
"Discord/Telegram audio file and voice backend settings",
"transcribed prompt routed to handler",
"missing optional extra or backend error shown to user",
(
"tests/messaging/test_voice_handlers.py",
"tests/messaging/test_transcription.py",
"tests/messaging/test_transcription_nim.py",
"tests/messaging/test_platform_voice_flow.py",
),
("test_voice_transcription_backend_when_explicitly_enabled",),
),
CapabilityContract(
"cli",
"package_entrypoints",
"package_cli_entrypoints",
"free_claude_code.cli.entrypoints",
"installed console scripts",
"config scaffold, package version, or uvicorn server startup",
"process cleanup in finally",
("tests/cli/test_entrypoints.py", "tests/core/test_version.py"),
(
"test_fcc_init_scaffolds_user_config",
"test_free_claude_code_entrypoint_starts_server",
"test_entrypoint_version_e2e",
),
),
CapabilityContract(
"cli",
"claude_cli_drop_in",
"claude_cli_drop_in",
"free_claude_code.cli.managed.session.ManagedClaudeSession",
"Claude CLI binary and proxy env",
"stream-json events and session id mapping",
"stderr/error event and process cleanup",
("tests/cli/test_cli.py",),
(
"test_claude_cli_prompt_when_available",
"test_claude_cli_provider_error_e2e",
"test_nvidia_nim_cli_matrix_e2e",
"test_openrouter_free_cli_matrix_e2e",
),
),
CapabilityContract(
"cli",
"codex_cli_drop_in",
"drop_in_codex_replacement",
"free_claude_code.cli.launchers.codex",
"Codex CLI binary and fcc provider env",
"Responses config, auth env, and native /model catalog injection",
"proxy preflight and catalog fail-open warning",
(
"tests/cli/test_codex_model_catalog.py",
"tests/cli/test_entrypoints.py",
),
(),
),
CapabilityContract(
"cli",
"pi_cli_integration",
"pi_cli_integration",
"free_claude_code.cli.launchers.pi",
"Pi CLI binary, bundled extension, and FCC child-process environment",
"Anthropic Messages provider with an FCC-scoped live model catalog",
"proxy preflight, missing extension, or catalog failure exits without fallback",
("tests/cli/test_entrypoints.py",),
("test_pi_cli_prompt_e2e",),
),
CapabilityContract(
"extensibility",
"provider_platform_abcs",
"extensible_provider_platform_abcs",
"free_claude_code.providers.runtime and free_claude_code.messaging.platforms.factory",
"new provider/platform implementations",
"registered BaseProvider or messaging component bundle",
"unknown platform returns None; unknown provider errors",
(
"tests/contracts/test_feature_manifest.py",
"tests/providers/test_provider_runtime.py",
),
),
)
def capability_names() -> set[str]:
return {contract.capability for contract in CAPABILITY_CONTRACTS}
def contracted_feature_ids() -> set[str]:
return {contract.feature_id for contract in CAPABILITY_CONTRACTS}
+126
View File
@@ -0,0 +1,126 @@
from collections.abc import Iterator
from typing import Any
import pytest
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.report import SmokeReport
from smoke.lib.server import RunningServer, start_server
DISABLED_PROVIDER_MODEL = ProviderModel(
provider="smoke_disabled",
full_model="smoke_disabled/smoke-disabled",
source="smoke_disabled",
)
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "provider_model" in metafunc.fixturenames:
config = SmokeConfig.load()
metafunc.parametrize("provider_model", provider_model_params(config))
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
if SmokeConfig.load().live:
return
skip = pytest.mark.skip(reason="set FCC_LIVE_SMOKE=1 to run local smoke tests")
for item in items:
item.add_marker(skip)
def pytest_configure(config: pytest.Config) -> None:
global _REPORT
smoke_config = SmokeConfig.load()
_REPORT = SmokeReport(smoke_config)
def pytest_runtest_setup(item: pytest.Item) -> None:
config = SmokeConfig.load()
target_marks = list(item.iter_markers("smoke_target"))
if not target_marks:
return
targets = [str(mark.args[0]) for mark in target_marks if mark.args]
if targets and not any(config.target_enabled(target) for target in targets):
pytest.skip(f"smoke target disabled: {', '.join(targets)}")
def pytest_runtest_logreport(report: pytest.TestReport) -> None:
if report.when == "setup" and not report.skipped:
return
if report.when == "teardown" and not report.failed:
return
if _REPORT is None:
return
markers = sorted(
str(name) for name in report.keywords if str(name).startswith("smoke_")
)
detail = "" if report.longrepr is None else str(report.longrepr)
_REPORT.add(
nodeid=report.nodeid,
outcome=report.outcome,
duration_s=report.duration,
markers=markers,
detail=detail,
)
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
if _REPORT is not None:
_REPORT.write()
@pytest.fixture(scope="session")
def smoke_config() -> SmokeConfig:
return SmokeConfig.load()
@pytest.fixture
def smoke_server(smoke_config: SmokeConfig) -> Iterator[RunningServer]:
with start_server(smoke_config) as server:
yield server
@pytest.fixture
def smoke_headers() -> dict[str, str]:
return auth_headers()
def provider_model_params(config: SmokeConfig) -> list[Any]:
"""Return provider params grouped for pytest-xdist ``--dist=loadgroup``."""
if not config.live:
return [_disabled_provider_param("set FCC_LIVE_SMOKE=1 to run provider smoke")]
models = config.provider_smoke_models()
if not models:
return [_disabled_provider_param("missing_env: no configured provider smoke")]
return [
pytest.param(
model,
id=provider_model_id(model),
marks=pytest.mark.xdist_group(provider_xdist_group(model)),
)
for model in models
]
def _disabled_provider_param(reason: str) -> Any:
return pytest.param(
DISABLED_PROVIDER_MODEL,
id=provider_model_id(DISABLED_PROVIDER_MODEL),
marks=(
pytest.mark.skip(reason=reason),
pytest.mark.xdist_group(provider_xdist_group(DISABLED_PROVIDER_MODEL)),
),
)
def provider_model_id(provider_model: ProviderModel) -> str:
return provider_model.provider
def provider_xdist_group(provider_model: ProviderModel) -> str:
return f"provider:{provider_model.provider}"
_REPORT: SmokeReport | None = None
+565
View File
@@ -0,0 +1,565 @@
"""Public feature inventory for contract, prerequisite, and product smoke tests.
The inventory is intentionally explicit. README-advertised behavior and exposed
public surface area must have deterministic pytest contract coverage plus a
product E2E scenario when that behavior is a user-facing product path. Liveness
and route probes live in ``smoke/prereq`` and do not count as product coverage.
"""
from dataclasses import dataclass
from typing import Literal
FeatureSource = Literal["readme", "public_surface"]
@dataclass(frozen=True, slots=True)
class FeatureCoverage:
feature_id: str
title: str
source: FeatureSource
pytest_contract_tests: tuple[str, ...]
live_prereq_tests: tuple[str, ...]
product_e2e_tests: tuple[str, ...]
smoke_targets: tuple[str, ...]
required_env: tuple[str, ...]
skip_policy: str
product_e2e_reason: str = ""
@property
def has_pytest_coverage(self) -> bool:
return bool(self.pytest_contract_tests)
README_FEATURES: tuple[str, ...] = (
"zero_cost_provider_access",
"drop_in_claude_code_replacement",
"drop_in_codex_replacement",
"pi_cli_integration",
"provider_matrix",
"per_model_mapping",
"thinking_token_support",
"heuristic_tool_parser",
"discord_telegram_bot",
"optional_authentication",
"vscode_extension",
"intellij_extension",
"voice_notes",
)
FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = (
FeatureCoverage(
"zero_cost_provider_access",
"Configured provider accepts real conversation turns",
"readme",
("tests/api/test_dependencies.py", "tests/providers/"),
("test_configured_provider_models_stream_successfully",),
("test_provider_text_multiturn_e2e",),
("providers",),
("configured provider credentials or local provider endpoint",),
"missing providers are missing_env unless FCC_ALLOW_NO_PROVIDER_SMOKE=1",
),
FeatureCoverage(
"drop_in_claude_code_replacement",
"Claude-compatible API, CLI, and editor protocol flows work",
"readme",
("tests/api/test_api.py", "tests/cli/test_cli.py"),
("test_probe_and_models_routes", "test_claude_cli_prompt_when_available"),
(
"test_api_basic_conversation_e2e",
"test_claude_cli_adaptive_thinking_e2e",
"test_claude_cli_provider_error_e2e",
"test_nvidia_nim_cli_matrix_e2e",
"test_openrouter_free_cli_matrix_e2e",
"test_vscode_protocol_e2e",
"test_jetbrains_protocol_e2e",
),
("api", "cli", "clients", "nvidia_nim_cli", "openrouter_free_cli"),
(
"configured provider",
"FCC_SMOKE_CLAUDE_BIN for real Claude CLI",
"NVIDIA_NIM_API_KEY",
"OPENROUTER_API_KEY",
),
"skip real CLI when binary is absent; configured providers must pass",
),
FeatureCoverage(
"drop_in_codex_replacement",
"OpenAI Responses API and Codex CLI adapter route through the proxy",
"readme",
(
"tests/api/test_openai_responses.py",
"tests/cli/test_entrypoints.py",
"tests/cli/test_codex_model_catalog.py",
"tests/core/openai_responses/test_sse.py",
),
("test_probe_and_models_routes",),
("test_provider_codex_responses_text_e2e",),
("api", "providers"),
("configured provider credentials or local provider endpoint",),
"missing providers are missing_env unless FCC_ALLOW_NO_PROVIDER_SMOKE=1",
),
FeatureCoverage(
"pi_cli_integration",
"Pi discovers FCC models and sends Anthropic Messages through the proxy",
"readme",
("tests/cli/test_entrypoints.py",),
("test_probe_and_models_routes",),
("test_pi_cli_prompt_e2e",),
("clients",),
("Pi CLI", "configured provider credentials or local provider endpoint"),
"skip only when Pi is absent; configured providers must pass",
),
FeatureCoverage(
"provider_matrix",
"Every configured provider prefix can satisfy conversation scenarios",
"readme",
("tests/api/test_dependencies.py", "tests/providers/"),
("test_configured_provider_models_stream_successfully",),
("test_provider_matrix_presence_e2e", "test_provider_text_multiturn_e2e"),
("providers",),
("configured provider credentials/endpoints", "optional FCC_SMOKE_MODEL_*"),
"selected providers missing credentials are failing missing_env",
),
FeatureCoverage(
"per_model_mapping",
"Opus, Sonnet, Haiku, and fallback mappings route explicitly",
"readme",
("tests/application/test_routing.py", "tests/config/test_config.py"),
("test_model_mapping_configuration_is_consistent",),
("test_model_mapping_matrix_e2e",),
("providers",),
("MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"),
"skip only when live smoke is intentionally allowed to run with no provider",
),
FeatureCoverage(
"mixed_provider_mapping",
"Model-specific overrides can route to different providers",
"public_surface",
("tests/application/test_routing.py", "tests/config/test_config.py"),
("test_mixed_provider_model_mapping_when_configured",),
("test_model_mapping_matrix_e2e",),
("providers",),
("MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"),
"configured mixed-provider mappings must resolve consistently",
),
FeatureCoverage(
"thinking_token_support",
"Thinking history, adaptive thinking, and redacted blocks are accepted",
"readme",
(
"tests/contracts/test_stream_contracts.py",
"tests/providers/test_open_router.py",
),
(),
(
"test_provider_adaptive_thinking_history_e2e",
"test_provider_reasoning_tool_continuation_e2e",
"test_gemini_thought_signature_tool_continuation_e2e",
"test_claude_cli_adaptive_thinking_e2e",
"test_per_model_thinking_config_e2e",
),
("providers", "cli", "config"),
("configured provider",),
"configured providers must not reject adaptive thinking payloads",
),
FeatureCoverage(
"heuristic_tool_parser",
"Tool use and tool result continuation survive provider/client paths",
"readme",
("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"),
("test_live_tool_use_when_configured_model_supports_tools",),
(
"test_provider_interleaved_thinking_tool_e2e",
"test_provider_tool_result_continuation_e2e",
"test_gemini_thought_signature_tool_continuation_e2e",
"test_provider_reasoning_tool_continuation_e2e",
),
("tools", "providers"),
("configured tool-capable provider",),
"tool-capable configured providers must emit or continue tool results",
),
FeatureCoverage(
"request_optimization",
"Local request optimizations return product responses without providers",
"public_surface",
(
"tests/api/test_optimization_handlers.py",
"tests/api/test_routes_optimizations.py",
),
("test_optimization_fast_paths_do_not_need_provider",),
("test_api_request_optimizations_e2e",),
("api",),
(),
"always runnable once the local smoke server starts",
),
FeatureCoverage(
"smart_rate_limiting",
"Transient retries and disconnect cleanup preserve follow-up requests",
"public_surface",
(
"tests/providers/test_provider_rate_limit.py",
"tests/providers/test_nvidia_nim_degraded_retry.py",
),
("test_client_disconnect_mid_stream_does_not_crash_server",),
("test_provider_disconnect_e2e",),
("rate_limit", "providers"),
("configured provider",),
"upstream disconnects are skips only when classified upstream_unavailable",
),
FeatureCoverage(
"provider_hot_swap",
"Provider config changes preserve active streams while new requests switch",
"public_surface",
(
"tests/runtime/test_provider_manager.py",
"tests/api/test_response_streams.py",
"tests/api/test_admin.py",
),
(),
("test_provider_hot_swap_preserves_inflight_stream_e2e",),
("api",),
(),
"credential-free local fake upstreams make the scenario always runnable",
),
FeatureCoverage(
"discord_telegram_bot",
"Discord and Telegram product flows render progress and transcripts",
"readme",
(
"tests/messaging/test_discord_platform.py",
"tests/messaging/test_telegram.py",
"tests/messaging/test_limiter.py",
"tests/messaging/test_platform_outbox.py",
"tests/runtime/test_application_runtime.py",
),
(
"test_telegram_bot_api_permissions",
"test_discord_bot_api_permissions",
),
(
"test_messaging_fake_full_flow_e2e",
"test_telegram_live_permissions_e2e",
"test_discord_live_permissions_e2e",
),
("messaging", "telegram", "discord"),
("bot tokens/channels only for side-effectful live platform tests",),
"fake platform is required; real platforms skip without explicit env",
),
FeatureCoverage(
"subagent_control",
"Task-like tool output is rendered and controlled as foreground work",
"public_surface",
("tests/providers/test_subagent_interception.py",),
(),
("test_messaging_subagent_control_e2e",),
("messaging",),
(),
"fake platform exercises tool transcript behavior without spawning agents",
),
FeatureCoverage(
"extensible_provider_platform_abcs",
"Provider and platform factories expose built-in extension points",
"public_surface",
(
"tests/contracts/test_feature_manifest.py",
"tests/providers/test_provider_runtime.py",
),
(),
("test_provider_runtime_config_e2e", "test_platform_factory_e2e"),
("extensibility",),
(),
"always runnable with isolated settings",
),
FeatureCoverage(
"optional_authentication",
"Anthropic-style auth headers are enforced and accepted",
"readme",
("tests/api/test_auth.py",),
("test_auth_token_is_enforced_for_all_supported_header_shapes",),
("test_api_auth_header_variants_e2e",),
("auth",),
("ANTHROPIC_AUTH_TOKEN",),
"product test starts an isolated token-protected server",
),
FeatureCoverage(
"vscode_extension",
"VS Code protocol-shaped requests work against the proxy",
"readme",
(
"tests/core/anthropic/test_models.py::test_messages_request_accepts_adaptive_thinking_type",
),
("test_vscode_and_jetbrains_shaped_requests",),
("test_vscode_protocol_e2e",),
("clients",),
("configured provider",),
"extension source is external; protocol payload is covered here",
),
FeatureCoverage(
"intellij_extension",
"JetBrains/ACP protocol-shaped requests work against the proxy",
"readme",
(
"tests/core/anthropic/test_models.py::test_messages_request_accepts_adaptive_thinking_type",
),
("test_vscode_and_jetbrains_shaped_requests",),
("test_jetbrains_protocol_e2e",),
("clients",),
("configured provider",),
"extension source is external; protocol payload is covered here",
),
FeatureCoverage(
"voice_notes",
"Voice note intake, cancellation, and transcription backends work",
"readme",
(
"tests/messaging/test_voice_handlers.py",
"tests/messaging/test_transcription.py",
),
("test_voice_transcription_backend_when_explicitly_enabled",),
(
"test_voice_platform_fake_e2e",
"test_voice_local_backend_e2e",
"test_voice_nim_backend_e2e",
),
("messaging", "voice"),
("VOICE_NOTE_ENABLED", "FCC_SMOKE_RUN_VOICE", "WHISPER_DEVICE"),
"fake cancellation is required; backend transcription is opt-in",
),
FeatureCoverage(
"anthropic_api_routes",
"Messages, count_tokens, errors, and stop use Anthropic-compatible shapes",
"public_surface",
("tests/api/test_api.py",),
("test_probe_and_models_routes", "test_stop_endpoint_reports_no_messaging"),
(
"test_api_basic_conversation_e2e",
"test_api_count_tokens_full_payload_e2e",
"test_api_error_shape_e2e",
"test_api_stop_e2e",
),
("api",),
("configured provider for streaming messages",),
"route pings are prereqs; product route behavior is covered by E2E cases",
),
FeatureCoverage(
"probe_routes",
"HEAD and OPTIONS compatibility probes are accepted",
"public_surface",
("tests/api/test_api.py::test_probe_endpoints_return_204_with_allow_headers",),
("test_probe_and_models_routes",),
(),
("api",),
(),
"always runnable once the local smoke server starts",
"probe routes are compatibility prerequisites, not product behavior",
),
FeatureCoverage(
"count_tokens_contract",
"Token counting accepts full Claude content payloads",
"public_surface",
("tests/api/test_request_utils.py",),
("test_count_tokens_accepts_thinking_tools_and_results",),
("test_api_count_tokens_full_payload_e2e",),
("api",),
(),
"always runnable once the local smoke server starts",
),
FeatureCoverage(
"provider_proxy_timeout_config",
"Provider proxies and HTTP timeout settings reach provider config",
"public_surface",
("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"),
(),
("test_proxy_timeout_config_e2e",),
("config",),
(),
"always runnable with isolated env files",
),
FeatureCoverage(
"lmstudio_endpoint",
"LM Studio Messages and local no-key operation work when running",
"public_surface",
("tests/providers/test_lmstudio.py",),
("test_lmstudio_models_endpoint_when_available",),
("test_lmstudio_messages_e2e",),
("lmstudio",),
("LM_STUDIO_BASE_URL with running LM Studio server",),
"skip when local upstream is unavailable",
),
FeatureCoverage(
"llamacpp_endpoint",
"llama.cpp OpenAI Chat and local no-key operation work when running",
"public_surface",
("tests/providers/test_llamacpp.py",),
("test_llamacpp_models_endpoint_when_available",),
("test_llamacpp_openai_chat_e2e",),
("llamacpp",),
("LLAMACPP_BASE_URL with running llama-server",),
"skip when local upstream is unavailable",
),
FeatureCoverage(
"ollama_endpoint",
"Ollama OpenAI Chat and local no-key operation work when running",
"public_surface",
("tests/providers/test_ollama.py",),
("test_ollama_models_endpoint_when_available",),
("test_ollama_openai_chat_e2e",),
("ollama",),
("OLLAMA_BASE_URL with running Ollama server",),
"skip when local upstream is unavailable",
),
FeatureCoverage(
"package_cli_entrypoints",
"Installed package scripts scaffold config, report version, and start the server",
"public_surface",
("tests/cli/test_entrypoints.py", "tests/core/test_version.py"),
(
"test_fcc_init_scaffolds_user_config",
"test_free_claude_code_entrypoint_starts_server",
),
(
"test_entrypoint_init_e2e",
"test_entrypoint_server_e2e",
"test_entrypoint_version_e2e",
),
("cli",),
(),
"always runnable once uv project dependencies are available",
),
FeatureCoverage(
"claude_cli_drop_in",
"Claude CLI can send adaptive thinking and tool-shaped history",
"public_surface",
("tests/cli/test_cli.py",),
("test_claude_cli_prompt_when_available",),
(
"test_claude_cli_adaptive_thinking_e2e",
"test_claude_cli_multiturn_tool_protocol_e2e",
"test_nvidia_nim_cli_matrix_e2e",
"test_openrouter_free_cli_matrix_e2e",
),
("cli", "nvidia_nim_cli", "openrouter_free_cli"),
(
"FCC_SMOKE_CLAUDE_BIN",
"configured provider",
"NVIDIA_NIM_API_KEY",
"OPENROUTER_API_KEY",
),
"skip only when Claude CLI binary is absent",
),
FeatureCoverage(
"messaging_commands",
"Messaging commands clear exact reply subtrees or whole managed chats",
"public_surface",
(
"tests/messaging/test_handler.py",
"tests/messaging/test_handler_integration.py",
"tests/messaging/test_tree_ownership_concurrency.py",
),
(),
(
"test_messaging_commands_stop_clear_stats_e2e",
"test_reply_clear_uses_literal_platform_subtree_e2e",
"test_messaging_startup_notice_is_clearable_e2e",
"test_messaging_active_stop_uses_status_only_e2e",
"test_messaging_queued_scoped_cancel_e2e",
),
("messaging",),
(),
"required fake-platform product flow",
),
FeatureCoverage(
"tree_threading",
"Reply-based branches fork sessions and stay scoped",
"public_surface",
(
"tests/messaging/test_tree_queue.py",
"tests/messaging/test_tree_concurrency.py",
"tests/messaging/test_message_tree_transitions.py",
"tests/messaging/test_tree_ownership_concurrency.py",
),
(),
(
"test_tree_threading_e2e",
"test_messaging_queued_scoped_cancel_e2e",
"test_same_message_ids_are_isolated_by_chat_e2e",
),
("messaging",),
(),
"required fake-platform product flow",
),
FeatureCoverage(
"restart_restore",
"Persisted tree state restores reply routing after restart",
"public_surface",
("tests/messaging/test_restart_reply_restore.py",),
(),
("test_restart_restore_and_session_persistence_e2e",),
("messaging",),
(),
"required fake-platform persistence flow",
),
FeatureCoverage(
"session_persistence",
"Session JSON preserves scoped trees and message logs",
"public_surface",
("tests/messaging/test_session_store_edge_cases.py",),
(),
("test_restart_restore_and_session_persistence_e2e",),
("messaging",),
(),
"required fake-platform persistence flow",
),
FeatureCoverage(
"config_env_precedence",
"FCC_ENV_FILE, dotenv, and process env precedence are deterministic",
"public_surface",
("tests/config/test_config.py",),
(),
("test_env_precedence_e2e",),
("config",),
(),
"always runnable with isolated env files",
),
FeatureCoverage(
"removed_env_migration",
"Removed thinking env vars are ignored without changing defaults",
"public_surface",
("tests/config/test_config.py",),
(),
("test_removed_env_migration_e2e",),
("config",),
(),
"always runnable with isolated env files",
),
FeatureCoverage(
"streaming_error_mapping",
"Canonical execution failures map to protocol-correct terminal output",
"public_surface",
(
"tests/api/test_execution_failure_contract.py",
"tests/core/test_failure_protocol_mapping.py",
"tests/providers/test_execution_failure_boundary.py",
"tests/providers/test_failure_policy.py",
),
(),
(
"test_api_error_shape_e2e",
"test_provider_error_e2e",
"test_claude_cli_provider_error_e2e",
),
("api", "providers", "cli"),
("configured provider for provider error scenario",),
"invalid request path is required; provider error path requires provider",
),
)
def feature_ids(*, source: FeatureSource | None = None) -> set[str]:
"""Return feature IDs covered by the inventory."""
return {
feature.feature_id
for feature in FEATURE_INVENTORY
if source is None or feature.source == source
}
+1
View File
@@ -0,0 +1 @@
"""Shared helpers for local-only smoke tests."""
+70
View File
@@ -0,0 +1,70 @@
"""Child-process commands for smoke (avoid nested ``uv run`` on Windows).
Nested ``uv run`` can try to refresh console scripts while they are locked
(``free-claude-code.exe`` in use), causing flaky smoke. The smoke runner is
already executed under the project environment (``uv run pytest``), so children
should use the same interpreter.
"""
import subprocess
import sys
from collections.abc import Mapping, Sequence
from pathlib import Path
def python_exe() -> str:
return sys.executable
def cmd_python_c(script: str) -> list[str]:
return [python_exe(), "-c", script]
def cmd_fcc_init() -> list[str]:
return [
python_exe(),
"-c",
"from free_claude_code.cli.entrypoints import init; init()",
]
def cmd_fcc_version() -> list[str]:
return [
python_exe(),
"-c",
(
"import sys; "
"sys.argv = ['fcc-server', '--version']; "
"from free_claude_code.cli.entrypoints import serve; serve()"
),
]
def cmd_free_claude_code_serve() -> list[str]:
return [
python_exe(),
"-c",
"from free_claude_code.cli.entrypoints import serve; serve()",
]
def run_captured_text(
command: Sequence[str],
*,
cwd: str | Path | None = None,
env: Mapping[str, str] | None = None,
timeout: float | None = None,
check: bool = False,
) -> subprocess.CompletedProcess[str]:
"""Run a smoke child process with deterministic captured text decoding."""
return subprocess.run(
list(command),
cwd=cwd,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=check,
)
+786
View File
@@ -0,0 +1,786 @@
"""Claude Code CLI characterization helpers for provider smoke matrices."""
import json
import os
import re
import subprocess
import time
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from free_claude_code.cli.claude_env import build_claude_proxy_env
from smoke.lib.child_process import run_captured_text
from smoke.lib.config import ProviderModel, SmokeConfig, redacted
from smoke.lib.server import RunningServer
REGRESSION_CLASSIFICATIONS = frozenset({"harness_bug", "product_failure"})
_HTTP_REGRESSION_PATTERNS = (
r'POST /v1/messages[^"\n]* HTTP/1\.1" 4(?!01|03|04|08|09)\d\d',
r'POST /v1/messages[^"\n]* HTTP/1\.1" 5\d\d',
)
_UPSTREAM_UNAVAILABLE_MARKERS = (
"upstream_unavailable",
"readtimeout",
"connecterror",
"connection refused",
"timed out",
"rate limit",
"overloaded",
"capacity",
"upstream provider",
"provider api request failed",
"httpstatuserror",
)
_HTTP_429_PATTERNS = (
r'HTTP/1\.[01]" 429\b',
r"\bHTTP/1\.[01] 429\b",
r"\bstatus_code=429\b",
r"\bstatus[=:]\s*429\b",
r"\b429 Too Many Requests\b",
)
_MISSING_ENV_MARKERS = (
"api key",
"not logged in",
"authentication",
"permission denied",
)
_EMPTY_MCP_CONFIG = '{"mcpServers":{}}'
_SUBAGENT_SYSTEM_PROMPT = (
"You are a deterministic smoke-test coordinator. Use Agent when asked to "
"use a subagent."
)
@dataclass(frozen=True, slots=True)
class ClaudeCliRun:
command: tuple[str, ...]
returncode: int | None
stdout: str
stderr: str
duration_s: float
timed_out: bool = False
@property
def combined_output(self) -> str:
return f"{self.stdout}\n{self.stderr}"
@dataclass(frozen=True, slots=True)
class CliMatrixOutcome:
model: str
full_model: str
source: str
feature: str
outcome: str
classification: str
duration_s: float
cli_returncode: int | None
token_evidence: dict[str, Any]
request_count: int
log_path: str
stdout_excerpt: str
stderr_excerpt: str
log_excerpt: str
def run_claude_cli(
*,
claude_bin: str,
server: RunningServer,
config: SmokeConfig,
cwd: Path,
prompt: str,
tools: str | None,
bare: bool = True,
pre_tool_args: tuple[str, ...] = (),
extra_args: tuple[str, ...] = (),
session_id: str | None = None,
resume_session_id: str | None = None,
no_session_persistence: bool = True,
) -> ClaudeCliRun:
"""Run Claude Code CLI against the local smoke proxy."""
cwd.mkdir(parents=True, exist_ok=True)
cmd = list(
_build_claude_cli_command(
claude_bin=claude_bin,
prompt=prompt,
tools=tools,
bare=bare,
pre_tool_args=pre_tool_args,
extra_args=extra_args,
session_id=session_id,
resume_session_id=resume_session_id,
no_session_persistence=no_session_persistence,
)
)
env = build_claude_proxy_env(
proxy_root_url=server.base_url,
auth_token=config.settings.anthropic_auth_token,
base_env=os.environ,
)
env["TERM"] = "dumb"
env["NO_COLOR"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
started = time.monotonic()
try:
result = run_captured_text(
cmd,
cwd=cwd,
env=env,
timeout=config.timeout_s,
check=False,
)
except subprocess.TimeoutExpired as exc:
return ClaudeCliRun(
command=tuple(cmd),
returncode=None,
stdout=_coerce_timeout_text(exc.stdout),
stderr=_coerce_timeout_text(exc.stderr),
duration_s=time.monotonic() - started,
timed_out=True,
)
return ClaudeCliRun(
command=tuple(cmd),
returncode=result.returncode,
stdout=_coerce_timeout_text(result.stdout),
stderr=_coerce_timeout_text(result.stderr),
duration_s=time.monotonic() - started,
)
def _build_claude_cli_command(
*,
claude_bin: str,
prompt: str,
tools: str | None,
bare: bool = True,
pre_tool_args: tuple[str, ...] = (),
extra_args: tuple[str, ...] = (),
session_id: str | None = None,
resume_session_id: str | None = None,
no_session_persistence: bool = True,
) -> tuple[str, ...]:
cmd: list[str] = [claude_bin]
if bare:
cmd.append("--bare")
if resume_session_id:
cmd.extend(["--resume", resume_session_id])
if session_id:
cmd.extend(["--session-id", session_id])
cmd.extend(
[
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--permission-mode",
"bypassPermissions",
"--dangerously-skip-permissions",
"--model",
"sonnet",
]
)
if no_session_persistence:
cmd.append("--no-session-persistence")
cmd.extend(pre_tool_args)
if tools is not None:
cmd.extend(["--tools", tools])
if tools:
cmd.extend(["--allowedTools", tools])
cmd.extend(extra_args)
cmd.extend(["-p", prompt])
return tuple(cmd)
def run_cli_feature_probes(
*,
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> list[CliMatrixOutcome]:
return [
_basic_text(
claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix
),
_thinking(
claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix
),
_tool_use_roundtrip(
claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix
),
_interleaved_thinking_tool(
claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix
),
_subagent_task(
claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix
),
_compact_command(
claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix
),
]
def read_log_offset(log_path: Path) -> int:
"""Return the current text length of a smoke server log."""
if not log_path.is_file():
return 0
return len(log_path.read_text(encoding="utf-8", errors="replace"))
def read_log_delta(log_path: Path, offset: int) -> str:
"""Return smoke server log text written after ``offset``."""
if not log_path.is_file():
return ""
text = log_path.read_text(encoding="utf-8", errors="replace")
return text[offset:]
def token_evidence(
*,
feature: str,
marker: str,
run: ClaudeCliRun,
log_delta: str,
) -> dict[str, Any]:
"""Collect compact evidence for a CLI feature probe."""
combined = f"{run.combined_output}\n{log_delta}"
lower = combined.lower()
return {
"feature": feature,
"marker_present": bool(marker and marker in combined),
"thinking_delta_count": combined.count("thinking_delta"),
"tool_use_count": combined.count('"tool_use"'),
"tool_result_count": combined.count('"tool_result"'),
"agent_catalog_present": _tool_catalog_has(log_delta, "Agent"),
"agent_tool_count": _agent_tool_count(combined),
"agent_result_count": _agent_result_count(combined),
"task_tool_count": combined.count('"name": "Task"')
+ combined.count('"name":"Task"'),
"run_in_background_false": "run_in_background" in combined and "false" in lower,
"compact_boundary": "compact_boundary" in combined,
"compact_metadata": "compact_metadata" in combined,
"http_422": 'HTTP/1.1" 422' in combined,
"http_500": bool(re.search(r'HTTP/1\.1" 5\d\d', combined)),
"timed_out": run.timed_out,
}
def classify_probe(
*,
run: ClaudeCliRun,
log_delta: str,
marker: str,
requires_tool_result: bool = False,
requires_agent: bool = False,
requires_task: bool = False,
requires_compact: bool = False,
) -> tuple[str, str]:
"""Classify a probe without failing compatibility characterization failures."""
combined = f"{run.combined_output}\n{log_delta}"
lower = combined.lower()
if _has_proxy_regression(log_delta):
return "failed", "product_failure"
if run.returncode != 0 and any(
marker_text in lower for marker_text in _MISSING_ENV_MARKERS
):
return "skipped", "missing_env"
if run.timed_out:
return "failed", "probe_timeout"
if requires_agent and not _tool_catalog_has(log_delta, "Agent"):
return "failed", "harness_bug"
marker_ok = not marker or marker in combined
tool_ok = not requires_tool_result or '"tool_result"' in combined
agent_ok = not requires_agent or (
_agent_tool_count(combined) > 0 and _agent_result_count(combined) > 0
)
task_ok = not requires_task or (
('"name": "Task"' in combined or '"name":"Task"' in combined)
and "run_in_background" in combined
and "false" in lower
)
compact_ok = not requires_compact or (
"compact_boundary" in combined
or "compact_metadata" in combined
or "/compact" in combined
or "compact" in lower
)
cli_ok = run.returncode == 0
if cli_ok and marker_ok and tool_ok and agent_ok and task_ok and compact_ok:
return "passed", "passed"
if _has_upstream_unavailable_text(combined):
return "failed", "upstream_unavailable"
if not _has_proxy_request(log_delta):
return "failed", "harness_bug"
return "failed", "model_feature_failure"
def make_outcome(
*,
model: str,
full_model: str,
source: str,
feature: str,
marker: str,
run: ClaudeCliRun,
log_delta: str,
log_path: Path,
requires_tool_result: bool = False,
requires_agent: bool = False,
requires_task: bool = False,
requires_compact: bool = False,
) -> CliMatrixOutcome:
"""Build one report outcome from a CLI run and its server log delta."""
outcome, classification = classify_probe(
run=run,
log_delta=log_delta,
marker=marker,
requires_tool_result=requires_tool_result,
requires_agent=requires_agent,
requires_task=requires_task,
requires_compact=requires_compact,
)
evidence = token_evidence(
feature=feature,
marker=marker,
run=run,
log_delta=log_delta,
)
return CliMatrixOutcome(
model=model,
full_model=full_model,
source=source,
feature=feature,
outcome=outcome,
classification=classification,
duration_s=round(run.duration_s, 3),
cli_returncode=run.returncode,
token_evidence=evidence,
request_count=_request_count(log_delta),
log_path=str(log_path),
stdout_excerpt=_excerpt(run.stdout),
stderr_excerpt=_excerpt(run.stderr),
log_excerpt=_excerpt(log_delta),
)
def write_matrix_report(
config: SmokeConfig,
outcomes: list[CliMatrixOutcome],
*,
target: str,
filename_prefix: str,
) -> Path:
"""Write a Claude CLI compatibility matrix report."""
config.results_dir.mkdir(parents=True, exist_ok=True)
path = (
config.results_dir
/ f"{filename_prefix}-matrix-{config.worker_id}-{int(time.time())}.json"
)
payload = {
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"worker_id": config.worker_id,
"target": target,
"models": sorted({outcome.full_model for outcome in outcomes}),
"outcomes": [asdict(outcome) for outcome in outcomes],
}
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
return path
def regression_failures(outcomes: list[CliMatrixOutcome]) -> list[str]:
"""Return report lines for classifications that should fail pytest."""
return [
f"{outcome.full_model} {outcome.feature}: {outcome.classification}"
for outcome in outcomes
if outcome.classification in REGRESSION_CLASSIFICATIONS
]
def _basic_text(
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> CliMatrixOutcome:
marker = _marker(marker_prefix, "BASIC")
return _run_probe(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
workspace=model_dir / "basic_text",
feature="basic_text",
marker=marker,
prompt=f"Reply with exactly {marker} and no other text.",
tools="",
)
def _thinking(
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> CliMatrixOutcome:
marker = _marker(marker_prefix, "THINK")
return _run_probe(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
workspace=model_dir / "thinking",
feature="thinking",
marker=marker,
prompt=(
"Think privately about the request, then reply with exactly "
f"{marker} and no other text."
),
tools="",
extra_args=("--effort", "high"),
)
def _tool_use_roundtrip(
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> CliMatrixOutcome:
marker = _marker(marker_prefix, "TOOL")
workspace = model_dir / "tool_use_roundtrip"
(workspace / "smoke-read.txt").parent.mkdir(parents=True, exist_ok=True)
(workspace / "smoke-read.txt").write_text(marker, encoding="utf-8")
return _run_probe(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
workspace=workspace,
feature="tool_use_roundtrip",
marker=marker,
prompt=(
"Use the Read tool to read smoke-read.txt. Reply with exactly the "
"secret token from that file and no other text."
),
tools="Read",
requires_tool_result=True,
)
def _interleaved_thinking_tool(
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> CliMatrixOutcome:
marker = _marker(marker_prefix, "INTERLEAVED")
workspace = model_dir / "interleaved_thinking_tool"
(workspace / "smoke-interleaved.txt").parent.mkdir(parents=True, exist_ok=True)
(workspace / "smoke-interleaved.txt").write_text(marker, encoding="utf-8")
return _run_probe(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
workspace=workspace,
feature="interleaved_thinking_tool",
marker=marker,
prompt=(
"Think privately, use Read on smoke-interleaved.txt, then reply with "
"exactly the secret token from that file and no other text."
),
tools="Read",
extra_args=("--effort", "high"),
requires_tool_result=True,
)
def _subagent_task(
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> CliMatrixOutcome:
marker = _marker(marker_prefix, "TASK")
workspace = model_dir / "subagent_task"
(workspace / "smoke-subagent.txt").parent.mkdir(parents=True, exist_ok=True)
(workspace / "smoke-subagent.txt").write_text(marker, encoding="utf-8")
agents = json.dumps(
{
"smoke_reader": {
"description": "Reads one requested file and returns its token.",
"prompt": (
"Read the requested file with Read and return only the token "
"inside it."
),
"tools": ["Read"],
"permissionMode": "bypassPermissions",
"background": False,
}
}
)
bare, tools, pre_tool_args, extra_args = _subagent_probe_options(agents)
return _run_probe(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
workspace=workspace,
feature="subagent_task",
marker=marker,
prompt=(
"Use the smoke_reader subagent to read smoke-subagent.txt. After the "
"first agent result, reply with exactly the token and stop. Do not "
"call any other tools."
),
tools=tools,
bare=bare,
pre_tool_args=pre_tool_args,
extra_args=extra_args,
requires_tool_result=True,
requires_agent=True,
)
def _subagent_probe_options(
agents: str,
) -> tuple[bool, str, tuple[str, ...], tuple[str, ...]]:
return (
False,
"Agent,Read",
(
"--setting-sources",
"local",
"--strict-mcp-config",
"--mcp-config",
_EMPTY_MCP_CONFIG,
"--system-prompt",
_SUBAGENT_SYSTEM_PROMPT,
),
("--agents", agents),
)
def _compact_command(
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
model_dir: Path,
marker_prefix: str,
) -> CliMatrixOutcome:
marker = _marker(marker_prefix, "COMPACT")
workspace = model_dir / "compact_command"
session_id = str(uuid.uuid4())
offset = read_log_offset(server.log_path)
first = run_claude_cli(
claude_bin=claude_bin,
server=server,
config=smoke_config,
cwd=workspace,
prompt=f"Remember this smoke token: {marker}. Reply with exactly {marker}.",
tools="",
session_id=session_id,
no_session_persistence=False,
)
second = run_claude_cli(
claude_bin=claude_bin,
server=server,
config=smoke_config,
cwd=workspace,
prompt=f"/compact preserve {marker}",
tools="",
resume_session_id=session_id,
no_session_persistence=False,
)
log_delta = read_log_delta(server.log_path, offset)
run = ClaudeCliRun(
command=(*first.command, "&&", *second.command),
returncode=second.returncode if first.returncode == 0 else first.returncode,
stdout=f"{first.stdout}\n{second.stdout}",
stderr=f"{first.stderr}\n{second.stderr}",
duration_s=first.duration_s + second.duration_s,
timed_out=first.timed_out or second.timed_out,
)
return make_outcome(
model=provider_model.model_name,
full_model=provider_model.full_model,
source=provider_model.source,
feature="compact_command",
marker="",
run=run,
log_delta=log_delta,
log_path=server.log_path,
requires_compact=True,
)
def _run_probe(
*,
claude_bin: str,
server: RunningServer,
smoke_config: SmokeConfig,
provider_model: ProviderModel,
workspace: Path,
feature: str,
marker: str,
prompt: str,
tools: str | None,
bare: bool = True,
pre_tool_args: tuple[str, ...] = (),
extra_args: tuple[str, ...] = (),
requires_tool_result: bool = False,
requires_agent: bool = False,
requires_task: bool = False,
) -> CliMatrixOutcome:
offset = read_log_offset(server.log_path)
run = run_claude_cli(
claude_bin=claude_bin,
server=server,
config=smoke_config,
cwd=workspace,
prompt=prompt,
tools=tools,
bare=bare,
pre_tool_args=pre_tool_args,
extra_args=extra_args,
)
log_delta = read_log_delta(server.log_path, offset)
return make_outcome(
model=provider_model.model_name,
full_model=provider_model.full_model,
source=provider_model.source,
feature=feature,
marker=marker,
run=run,
log_delta=log_delta,
log_path=server.log_path,
requires_tool_result=requires_tool_result,
requires_agent=requires_agent,
requires_task=requires_task,
)
def _has_proxy_regression(log_delta: str) -> bool:
if "CREATE_MESSAGE_ERROR" in log_delta:
return True
return any(re.search(pattern, log_delta) for pattern in _HTTP_REGRESSION_PATTERNS)
def _has_proxy_request(log_delta: str) -> bool:
return (
"POST /v1/messages" in log_delta
or "API_REQUEST:" in log_delta
or '"event": "free_claude_code.api.request.received"' in log_delta
or (
'"http_method": "POST"' in log_delta
and '"http_path": "/v1/messages"' in log_delta
)
)
def _tool_catalog_has(log_delta: str, tool_name: str) -> bool:
catalog = _first_tool_catalog(log_delta)
return (
f"'name': '{tool_name}'" in catalog
or f'"name": "{tool_name}"' in catalog
or f'"name":"{tool_name}"' in catalog
)
def _first_tool_catalog(log_delta: str) -> str:
for line in log_delta.splitlines():
if "FULL_PAYLOAD" not in line:
continue
single_index = line.find("'tools': [")
double_index = line.find('"tools": [')
if single_index == -1 and double_index == -1:
continue
start = single_index if single_index != -1 else double_index
end_candidates = [
index
for marker in ("'tool_choice'", '"tool_choice"', "'thinking'", '"thinking"')
if (index := line.find(marker, start)) != -1
]
end = min(end_candidates) if end_candidates else len(line)
return line[start:end]
return ""
def _agent_tool_count(text: str) -> int:
return (
text.count('"name": "Agent"')
+ text.count('"name":"Agent"')
+ len(
re.findall(
r"'type': 'tool_use'[^}\n]+?'name': 'Agent'",
text,
flags=re.DOTALL,
)
)
)
def _agent_result_count(text: str) -> int:
return text.count("agentId:") + text.count('"agentId"') + text.count("'agentId'")
def _has_upstream_unavailable_text(text: str) -> bool:
lower = text.lower()
if any(marker_text in lower for marker_text in _UPSTREAM_UNAVAILABLE_MARKERS):
return True
return any(
re.search(pattern, text, flags=re.IGNORECASE) for pattern in _HTTP_429_PATTERNS
)
def _request_count(log_delta: str) -> int:
access_log_count = log_delta.count("POST /v1/messages")
service_log_count = log_delta.count("API_REQUEST:")
structured_log_count = log_delta.count(
'"event": "free_claude_code.api.request.received"'
)
return max(access_log_count, service_log_count, structured_log_count)
def _marker(scope: str, prefix: str) -> str:
return f"FCC_{scope}_{prefix}_{uuid.uuid4().hex[:8].upper()}"
def _excerpt(value: str | None, *, max_chars: int = 2400) -> str:
if value is None:
value = ""
if len(value) <= max_chars:
return redacted(value)
return redacted(value[-max_chars:])
def _coerce_timeout_text(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value
+445
View File
@@ -0,0 +1,445 @@
"""Smoke-suite configuration loaded from the real developer environment."""
import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from free_claude_code.config.model_refs import parse_model_name, parse_provider_type
from free_claude_code.config.provider_catalog import (
PROVIDER_CATALOG,
SUPPORTED_PROVIDER_IDS,
)
from free_claude_code.config.settings import Settings, get_settings
DEFAULT_TARGETS = frozenset(
{
"api",
"auth",
"cli",
"clients",
"config",
"extensibility",
"llamacpp",
"lmstudio",
"messaging",
"ollama",
"providers",
"rate_limit",
"tools",
}
)
SIDE_EFFECT_TARGETS = frozenset({"discord", "telegram", "voice"})
OPT_IN_TARGETS = frozenset({"nvidia_nim_cli", "openrouter_free_cli"})
ALL_TARGETS = DEFAULT_TARGETS | SIDE_EFFECT_TARGETS | OPT_IN_TARGETS
TARGET_ALIASES = {
"contract": "api",
"nim_cli": "nvidia_nim_cli",
"openrouter_cli": "openrouter_free_cli",
"openrouter_free": "openrouter_free_cli",
"optimizations": "api",
"thinking": "providers",
"vscode": "clients",
}
SECRET_KEY_PARTS = ("KEY", "TOKEN", "SECRET", "WEBHOOK", "AUTH")
PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
"nvidia_nim": "nvidia_nim/nvidia/nemotron-3-super-120b-a12b",
"open_router": "open_router/moonshotai/kimi-k2.6:free",
"mistral": "mistral/devstral-small-latest",
"mistral_codestral": "mistral_codestral/codestral-latest",
"deepseek": "deepseek/deepseek-v4-pro",
"lmstudio": "lmstudio/local-model",
"llamacpp": "llamacpp/local-model",
"ollama": "ollama/llama3.1",
"wafer": "wafer/DeepSeek-V4-Pro",
"minimax": "minimax/MiniMax-M3",
"opencode": "opencode/gpt-5.3-codex",
"opencode_go": "opencode_go/minimax-m2.7",
"vercel": "vercel/openai/gpt-5.5",
"huggingface": "huggingface/openai/gpt-oss-120b:fastest",
"cohere": "cohere/command-a-plus-05-2026",
"github_models": "github_models/openai/gpt-4.1",
"zai": "zai/glm-5.2",
"gemini": "gemini/models/gemini-3.1-flash-lite",
"groq": "groq/llama-3.3-70b-versatile",
"sambanova": "sambanova/Meta-Llama-3.3-70B-Instruct",
"cerebras": "cerebras/llama3.1-8b",
"cloudflare": "cloudflare/@cf/moonshotai/kimi-k2.6",
}
MISTRAL_REASONING_SMOKE_DEFAULT_MODEL = "mistral/mistral-medium-3-5"
NVIDIA_NIM_CLI_DEFAULT_MODELS: tuple[str, ...] = (
"z-ai/glm-5.2",
"moonshotai/kimi-k2.6",
"minimaxai/minimax-m2.7",
"nvidia/nemotron-3-super-120b-a12b",
"deepseek-ai/deepseek-v4-pro",
"deepseek-ai/deepseek-v4-flash",
)
OPENROUTER_FREE_CLI_DEFAULT_MODELS: tuple[str, ...] = (
"nvidia/nemotron-3-super-120b-a12b:free",
"openai/gpt-oss-120b:free",
"poolside/laguna-m.1:free",
)
TARGET_REQUIRED_ENV: dict[str, tuple[str, ...]] = {
"api": (),
"auth": (),
"cli": ("FCC_SMOKE_CLAUDE_BIN", "configured provider for Claude CLI prompt"),
"clients": (),
"config": (),
"extensibility": (),
"messaging": (),
"providers": ("configured provider credentials/endpoints or FCC_SMOKE_MODEL_*",),
"rate_limit": ("configured provider model",),
"tools": ("configured tool-capable provider model",),
"lmstudio": ("LM_STUDIO_BASE_URL with a running LM Studio server",),
"llamacpp": ("LLAMACPP_BASE_URL with a running llama-server",),
"ollama": ("OLLAMA_BASE_URL with a running Ollama server",),
"nvidia_nim_cli": (
"NVIDIA_NIM_API_KEY",
"FCC_SMOKE_CLAUDE_BIN or claude on PATH",
),
"openrouter_free_cli": (
"OPENROUTER_API_KEY",
"FCC_SMOKE_CLAUDE_BIN or claude on PATH",
),
"telegram": (
"TELEGRAM_BOT_TOKEN",
"ALLOWED_TELEGRAM_USER_ID or FCC_SMOKE_TELEGRAM_CHAT_ID",
),
"discord": (
"DISCORD_BOT_TOKEN",
"ALLOWED_DISCORD_CHANNELS or FCC_SMOKE_DISCORD_CHANNEL_ID",
),
"voice": ("VOICE_NOTE_ENABLED=true", "FCC_SMOKE_RUN_VOICE=1"),
}
@dataclass(frozen=True, slots=True)
class ProviderModel:
provider: str
full_model: str
source: str
@property
def model_name(self) -> str:
return parse_model_name(self.full_model)
@dataclass(frozen=True, slots=True)
class SmokeConfig:
root: Path
results_dir: Path
live: bool
interactive: bool
targets: frozenset[str]
provider_matrix: frozenset[str]
timeout_s: float
prompt: str
claude_bin: str
worker_id: str
settings: Settings
@classmethod
def load(cls) -> SmokeConfig:
root = Path(__file__).resolve().parents[2]
get_settings.cache_clear()
settings = get_settings()
return cls(
root=root,
results_dir=root / ".smoke-results",
live=os.getenv("FCC_LIVE_SMOKE") == "1",
interactive=os.getenv("FCC_SMOKE_INTERACTIVE") == "1",
targets=_parse_targets(os.getenv("FCC_SMOKE_TARGETS")),
provider_matrix=_parse_csv(os.getenv("FCC_SMOKE_PROVIDER_MATRIX")),
timeout_s=float(os.getenv("FCC_SMOKE_TIMEOUT_S", "45")),
prompt=os.getenv("FCC_SMOKE_PROMPT", "Reply with exactly: FCC_SMOKE_PONG"),
claude_bin=os.getenv("FCC_SMOKE_CLAUDE_BIN", "claude"),
worker_id=os.getenv("PYTEST_XDIST_WORKER", "main"),
settings=settings,
)
def target_enabled(self, *names: str) -> bool:
return any(name in self.targets for name in names)
def provider_models(self) -> list[ProviderModel]:
candidates = (
("MODEL", self.settings.model),
("MODEL_OPUS", self.settings.model_opus),
("MODEL_SONNET", self.settings.model_sonnet),
("MODEL_HAIKU", self.settings.model_haiku),
)
seen: set[str] = set()
models: list[ProviderModel] = []
for source, model in candidates:
if not model or model in seen:
continue
provider = parse_provider_type(model)
if self.provider_matrix and provider not in self.provider_matrix:
continue
if not self.has_provider_configuration(provider):
continue
seen.add(model)
models.append(
ProviderModel(provider=provider, full_model=model, source=source)
)
return models
def provider_smoke_models(self) -> list[ProviderModel]:
"""Return one smoke model per configured provider, independent of MODEL_*."""
models: list[ProviderModel] = []
mapped_providers = {model.provider for model in self.provider_models()}
for provider in SUPPORTED_PROVIDER_IDS:
if self.provider_matrix and provider not in self.provider_matrix:
continue
if not self.has_provider_configuration(provider):
continue
if not self._include_provider_in_smoke(provider, mapped_providers):
continue
full_model, source = _provider_smoke_model(provider)
models.append(
ProviderModel(provider=provider, full_model=full_model, source=source)
)
return models
def nvidia_nim_cli_models(self) -> list[ProviderModel]:
"""Return the NVIDIA NIM models for Claude Code CLI characterization."""
return [
ProviderModel(provider="nvidia_nim", full_model=full_model, source=source)
for full_model, source in nvidia_nim_cli_model_refs().items()
]
def openrouter_free_cli_models(self) -> list[ProviderModel]:
"""Return OpenRouter free models for Claude Code CLI characterization."""
return [
ProviderModel(provider="open_router", full_model=full_model, source=source)
for full_model, source in openrouter_free_cli_model_refs().items()
]
def mistral_reasoning_smoke_model(self) -> ProviderModel | None:
"""Return a Mistral model expected to accept native reasoning input."""
if self.provider_matrix and "mistral" not in self.provider_matrix:
return None
if not self.has_provider_configuration("mistral"):
return None
override_env = "FCC_SMOKE_MODEL_MISTRAL_REASONING"
if override := os.getenv(override_env):
full_model = _normalize_provider_model("mistral", override)
source = override_env
else:
full_model = MISTRAL_REASONING_SMOKE_DEFAULT_MODEL
source = "mistral_reasoning_default"
return ProviderModel(provider="mistral", full_model=full_model, source=source)
def _include_provider_in_smoke(
self, provider: str, mapped_providers: set[str]
) -> bool:
descriptor = PROVIDER_CATALOG[provider]
if not descriptor.local:
return True
if provider in mapped_providers:
return True
if self.provider_matrix and provider in self.provider_matrix:
return True
return bool(os.getenv(f"FCC_SMOKE_MODEL_{provider.upper()}"))
def has_provider_configuration(self, provider: str) -> bool:
if provider == "nvidia_nim":
return bool(self.settings.nvidia_nim_api_key.strip())
if provider == "open_router":
return bool(self.settings.open_router_api_key.strip())
if provider == "mistral":
return bool(self.settings.mistral_api_key.strip())
if provider == "mistral_codestral":
return bool(self.settings.codestral_api_key.strip())
if provider == "deepseek":
return bool(self.settings.deepseek_api_key.strip())
if provider == "kimi":
return bool(self.settings.kimi_api_key.strip())
if provider == "lmstudio":
return bool(self.settings.lm_studio_base_url.strip())
if provider == "llamacpp":
return bool(self.settings.llamacpp_base_url.strip())
if provider == "ollama":
return bool(self.settings.ollama_base_url.strip())
if provider == "wafer":
return bool(self.settings.wafer_api_key.strip())
if provider == "minimax":
return bool(self.settings.minimax_api_key.strip())
if provider == "fireworks":
return bool(self.settings.fireworks_api_key.strip())
if provider == "opencode":
return bool(self.settings.opencode_api_key.strip())
if provider == "opencode_go":
return bool(self.settings.opencode_api_key.strip())
if provider == "vercel":
return bool(self.settings.vercel_ai_gateway_api_key.strip())
if provider == "huggingface":
return bool(self.settings.huggingface_api_key.strip())
if provider == "cohere":
return bool(self.settings.cohere_api_key.strip())
if provider == "github_models":
return bool(self.settings.github_models_token.strip())
if provider == "zai":
return bool(self.settings.zai_api_key.strip())
if provider == "gemini":
return bool(self.settings.gemini_api_key.strip())
if provider == "groq":
return bool(self.settings.groq_api_key.strip())
if provider == "sambanova":
return bool(self.settings.sambanova_api_key.strip())
if provider == "cerebras":
return bool(self.settings.cerebras_api_key.strip())
if provider == "cloudflare":
return bool(
self.settings.cloudflare_api_token.strip()
and self.settings.cloudflare_account_id.strip()
)
return False
def _parse_csv(raw: str | None) -> frozenset[str]:
if not raw:
return frozenset()
return frozenset(part.strip() for part in raw.split(",") if part.strip())
def _parse_csv_ordered(raw: str | None) -> tuple[str, ...]:
if not raw:
return ()
return tuple(part.strip() for part in raw.split(",") if part.strip())
def _parse_targets(raw: str | None) -> frozenset[str]:
if not raw:
return DEFAULT_TARGETS
parsed = _parse_csv(raw)
if "all" in parsed:
return ALL_TARGETS
return frozenset(TARGET_ALIASES.get(target, target) for target in parsed)
def _provider_smoke_model(provider: str) -> tuple[str, str]:
override_env = f"FCC_SMOKE_MODEL_{provider.upper()}"
if override := os.getenv(override_env):
return _normalize_provider_model(provider, override), override_env
default = PROVIDER_SMOKE_DEFAULT_MODELS.get(provider)
if default is None:
descriptor = PROVIDER_CATALOG[provider]
default = f"{descriptor.provider_id}/smoke-default"
return default, "provider_default"
def _normalize_provider_model(provider: str, raw_model: str) -> str:
model = raw_model.strip()
if not model:
msg = f"FCC_SMOKE_MODEL_{provider.upper()} must not be empty"
raise ValueError(msg)
if "/" not in model:
return f"{provider}/{model}"
prefix = parse_provider_type(model)
if prefix == provider:
return model
if prefix in SUPPORTED_PROVIDER_IDS:
msg = (
f"FCC_SMOKE_MODEL_{provider.upper()} must use provider prefix "
f"{provider!r}, got {model!r}"
)
raise ValueError(msg)
return f"{provider}/{model}"
def nvidia_nim_cli_model_refs(
env: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Return normalized NIM CLI matrix model refs in deterministic order.
Values are returned as ``full_model -> source`` so callers can preserve both
de-duplicated order and provenance in reports.
"""
source = env if env is not None else os.environ
explicit_models = _parse_csv_ordered(source.get("FCC_SMOKE_NIM_MODELS"))
extra_models = _parse_csv_ordered(source.get("FCC_SMOKE_NIM_EXTRA_MODELS"))
if "FCC_SMOKE_NIM_MODELS" in source and not explicit_models:
raise ValueError("FCC_SMOKE_NIM_MODELS must list at least one model")
models: list[tuple[str, str]] = []
base_models = explicit_models or NVIDIA_NIM_CLI_DEFAULT_MODELS
base_source = (
"FCC_SMOKE_NIM_MODELS" if explicit_models else "nvidia_nim_cli_default"
)
models.extend((model, base_source) for model in base_models)
models.extend((model, "FCC_SMOKE_NIM_EXTRA_MODELS") for model in extra_models)
normalized: dict[str, str] = {}
for raw_model, model_source in models:
full_model = _normalize_provider_model("nvidia_nim", raw_model)
normalized.setdefault(full_model, model_source)
return normalized
def openrouter_free_cli_model_refs(
env: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Return normalized OpenRouter free CLI matrix model refs in deterministic order."""
source = env if env is not None else os.environ
explicit_models = _parse_csv_ordered(source.get("FCC_SMOKE_OPENROUTER_FREE_MODELS"))
extra_models = _parse_csv_ordered(
source.get("FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS")
)
if "FCC_SMOKE_OPENROUTER_FREE_MODELS" in source and not explicit_models:
raise ValueError(
"FCC_SMOKE_OPENROUTER_FREE_MODELS must list at least one model"
)
models: list[tuple[str, str]] = []
base_models = explicit_models or OPENROUTER_FREE_CLI_DEFAULT_MODELS
base_source = (
"FCC_SMOKE_OPENROUTER_FREE_MODELS"
if explicit_models
else "openrouter_free_cli_default"
)
models.extend((model, base_source) for model in base_models)
models.extend(
(model, "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS") for model in extra_models
)
normalized: dict[str, str] = {}
for raw_model, model_source in models:
full_model = _normalize_provider_model("open_router", raw_model)
normalized.setdefault(full_model, model_source)
return normalized
def auth_headers(token: str | None = None) -> dict[str, str]:
settings = get_settings()
resolved = token if token is not None else settings.anthropic_auth_token
headers = {
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
if resolved:
headers["x-api-key"] = resolved
return headers
def redacted(value: str, env: Mapping[str, str] | None = None) -> str:
"""Redact known secrets from a string before writing smoke artifacts."""
if not value:
return value
source = env if env is not None else os.environ
result = value
for key, secret in source.items():
if not secret or len(secret) < 4:
continue
if any(part in key.upper() for part in SECRET_KEY_PARTS):
result = result.replace(secret, f"<redacted:{key}>")
return result
+756
View File
@@ -0,0 +1,756 @@
"""Reusable product E2E smoke drivers."""
import asyncio
import json
import os
import subprocess
import time
import uuid
import wave
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
import pytest
from free_claude_code.cli.claude_env import build_claude_proxy_env
from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS
from free_claude_code.core.anthropic.stream_contracts import (
SSEEvent,
assert_anthropic_stream_contract,
event_index,
has_tool_use,
parse_sse_lines,
text_content,
)
from free_claude_code.messaging.models import IncomingMessage, MessageScope
from free_claude_code.messaging.session import SessionStore
from free_claude_code.messaging.voice import VoiceCancellationResult
from free_claude_code.messaging.workflow import MessagingWorkflow
from smoke.lib.child_process import run_captured_text
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.server import RunningServer, start_server
from smoke.lib.skips import fail_missing_env
@dataclass(slots=True)
class ConversationTurn:
request: dict[str, Any]
events: list[SSEEvent]
@property
def assistant_content(self) -> list[dict[str, Any]]:
return assistant_content_from_events(self.events)
@property
def text(self) -> str:
return text_content(self.events)
class SmokeServerDriver:
"""Start a local proxy server for a product scenario."""
def __init__(
self,
config: SmokeConfig,
*,
name: str,
env_overrides: dict[str, str] | None = None,
command: list[str] | None = None,
) -> None:
self.config = config
self.name = name
self.env_overrides = env_overrides
self.command = command
@contextmanager
def run(self) -> Iterator[RunningServer]:
with start_server(
self.config,
env_overrides=self.env_overrides,
command=self.command,
name=self.name,
) as server:
yield server
class ConversationDriver:
"""Drive multi-turn Anthropic-compatible conversations through the server."""
def __init__(self, server: RunningServer, config: SmokeConfig) -> None:
self.server = server
self.config = config
self.messages: list[dict[str, Any]] = []
self.turns: list[ConversationTurn] = []
def ask(
self,
text: str,
*,
model: str = "fcc-smoke-default",
max_tokens: int = 256,
extra: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
append_assistant: bool = True,
) -> ConversationTurn:
self.messages.append({"role": "user", "content": text})
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": list(self.messages),
}
if extra:
payload.update(extra)
turn = self.stream(payload, headers=headers)
if append_assistant:
self.messages.append(
{"role": "assistant", "content": turn.assistant_content or turn.text}
)
return turn
def stream(
self,
payload: dict[str, Any],
*,
headers: dict[str, str] | None = None,
) -> ConversationTurn:
request_headers = headers or auth_headers()
with httpx.stream(
"POST",
f"{self.server.base_url}/v1/messages",
headers=request_headers,
json=payload,
timeout=self.config.timeout_s,
) as response:
if response.status_code != 200:
body = response.read().decode("utf-8", errors="replace")
raise AssertionError(
f"stream request failed: HTTP {response.status_code} {body[:1000]}"
)
events = parse_sse_lines(response.iter_lines())
assert_anthropic_stream_contract(events)
turn = ConversationTurn(payload, events)
self.turns.append(turn)
return turn
def stream_expect_http_error(
self,
payload: dict[str, Any],
*,
expected_status: int,
) -> dict[str, Any]:
response = httpx.post(
f"{self.server.base_url}/v1/messages",
headers=auth_headers(),
json=payload,
timeout=self.config.timeout_s,
)
assert response.status_code == expected_status, response.text
return response.json()
class ProviderMatrixDriver:
"""Resolve provider models and enforce matrix semantics for product smoke."""
ALL_PROVIDERS: tuple[str, ...] = SUPPORTED_PROVIDER_IDS
def __init__(self, config: SmokeConfig) -> None:
self.config = config
def configured_models(self) -> list[ProviderModel]:
return self.config.provider_models()
def provider_smoke_models(self) -> list[ProviderModel]:
selected = self.config.provider_matrix
missing_selected = [
provider
for provider in selected
if provider in self.ALL_PROVIDERS
and not self.config.has_provider_configuration(provider)
]
if missing_selected:
fail_missing_env(
"selected providers are not configured: "
+ ", ".join(sorted(missing_selected))
)
models = self.config.provider_smoke_models()
if not models and os.getenv("FCC_ALLOW_NO_PROVIDER_SMOKE") != "1":
fail_missing_env(
"no configured provider smoke models; set FCC_ALLOW_NO_PROVIDER_SMOKE=1 "
"only for no-provider smoke collection"
)
return models
def first_model(self) -> ProviderModel:
models = self.provider_smoke_models()
if not models:
pytest.skip("missing_env: no configured provider model")
return models[0]
class ClientProtocolDriver:
"""Build recorded/representative client protocol requests."""
@staticmethod
def vscode_headers() -> dict[str, str]:
headers = auth_headers()
headers.update(
{
"anthropic-beta": "messages-2023-12-15",
"user-agent": "Claude-Code-VSCode product smoke",
}
)
return headers
@staticmethod
def jetbrains_headers(config: SmokeConfig) -> dict[str, str]:
headers = auth_headers()
token = config.settings.anthropic_auth_token
if token:
headers.pop("x-api-key", None)
headers["authorization"] = f"Bearer {token}"
headers["user-agent"] = "JetBrains-ACP product smoke"
return headers
@staticmethod
def adaptive_thinking_payload() -> dict[str, Any]:
return {
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "unsigned thought"},
{"type": "redacted_thinking", "data": "opaque"},
{"type": "text", "text": "Hello."},
],
},
{"role": "user", "content": "Reply with exactly FCC_SMOKE_CLIENT"},
],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
@staticmethod
def tool_result_payload() -> dict[str, Any]:
return {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Use echo_smoke once."},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_client_smoke",
"name": "echo_smoke",
"input": {"value": "FCC_SMOKE_CLIENT"},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_client_smoke",
"content": "FCC_SMOKE_CLIENT",
}
],
},
],
"tools": [echo_tool_schema()],
"thinking": {"type": "adaptive"},
}
@staticmethod
def run_claude_prompt(
*,
claude_bin: str,
server: RunningServer,
config: SmokeConfig,
cwd: Path,
prompt: str,
model: str | None = None,
) -> subprocess.CompletedProcess[str]:
env = build_claude_proxy_env(
proxy_root_url=server.base_url,
auth_token=config.settings.anthropic_auth_token,
base_env=os.environ,
)
command = [
claude_bin,
"--bare",
"--no-session-persistence",
"--tools",
"",
"--system-prompt",
"Reply with exactly the requested smoke token and no other text.",
]
if model is not None:
command.extend(["--model", model])
command.extend(["-p", prompt])
return run_captured_text(
command,
cwd=cwd,
env=env,
timeout=config.timeout_s,
check=False,
)
class FakePlatform:
"""In-memory platform that exercises the real message handler."""
def __init__(self, name: str) -> None:
self.name = name
self.handler: Callable[[IncomingMessage], Awaitable[None]] | None = None
self.sent: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = []
self.deletes: list[dict[str, Any]] = []
self._counter = 0
self._tasks: list[asyncio.Future[Any]] = []
self._pending_voice: dict[
tuple[MessageScope, str], VoiceCancellationResult
] = {}
async def start(self) -> None:
return None
async def quiesce(self) -> None:
return None
async def close(self) -> None:
for task in self._tasks:
if not task.done():
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
@property
def is_connected(self) -> bool:
return True
def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None:
self.handler = handler
def continue_message_sequence_after(self, previous: FakePlatform) -> None:
"""Model platform-owned message IDs surviving an FCC restart."""
self._counter = previous._counter
async def emit(self, incoming: IncomingMessage) -> None:
assert self.handler is not None
await self.handler(incoming)
def fire_and_forget(self, task: Awaitable[Any]) -> None:
self._tasks.append(asyncio.ensure_future(task))
async def send_message(
self,
chat_id: str,
text: str,
reply_to: str | None = None,
parse_mode: str | None = None,
message_thread_id: str | None = None,
) -> str:
self._counter += 1
message_id = f"{self.name}_msg_{self._counter}"
self.sent.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"reply_to": reply_to,
"parse_mode": parse_mode,
"message_thread_id": message_thread_id,
}
)
return message_id
async def edit_message(
self,
chat_id: str,
message_id: str,
text: str,
parse_mode: str | None = None,
) -> None:
self.edits.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"parse_mode": parse_mode,
}
)
async def delete_message(self, chat_id: str, message_id: str) -> None:
self.deletes.append({"chat_id": chat_id, "message_id": message_id})
async def queue_send_message(
self,
chat_id: str,
text: str,
reply_to: str | None = None,
parse_mode: str | None = None,
fire_and_forget: bool = True,
message_thread_id: str | None = None,
) -> str | None:
message_id = await self.send_message(
chat_id,
text,
reply_to=reply_to,
parse_mode=parse_mode,
message_thread_id=message_thread_id,
)
return None if fire_and_forget else message_id
async def queue_edit_message(
self,
chat_id: str,
message_id: str,
text: str,
parse_mode: str | None = None,
fire_and_forget: bool = True,
) -> None:
await self.edit_message(chat_id, message_id, text, parse_mode=parse_mode)
async def queue_delete_messages(
self,
chat_id: str,
message_ids: list[str],
fire_and_forget: bool = True,
) -> None:
for message_id in message_ids:
await self.delete_message(chat_id, message_id)
def seed_pending_voice(
self, chat_id: str, voice_message_id: str, status_message_id: str
) -> None:
scope = MessageScope(platform=self.name, chat_id=chat_id)
result = VoiceCancellationResult(
scope=scope,
voice_message_id=voice_message_id,
status_message_id=status_message_id,
delete_message_ids=frozenset({voice_message_id, status_message_id}),
)
self._pending_voice[(scope, voice_message_id)] = result
self._pending_voice[(scope, status_message_id)] = result
async def cancel_pending_voice(
self, scope: MessageScope, reply_id: str
) -> VoiceCancellationResult | None:
result = self._pending_voice.get((scope, reply_id))
if result is None:
return None
self._pending_voice.pop((scope, result.voice_message_id), None)
if result.status_message_id is not None:
self._pending_voice.pop((scope, result.status_message_id), None)
delete_message_ids = {result.voice_message_id, result.status_message_id}
if reply_id == result.status_message_id:
delete_message_ids = {result.status_message_id}
return VoiceCancellationResult(
scope=result.scope,
voice_message_id=result.voice_message_id,
status_message_id=result.status_message_id,
delete_message_ids=frozenset(
message_id
for message_id in delete_message_ids
if message_id is not None
),
)
async def cancel_all_pending_voices(
self,
) -> tuple[VoiceCancellationResult, ...]:
results = tuple(
{
(result.scope, result.voice_message_id): result
for result in self._pending_voice.values()
}.values()
)
self._pending_voice.clear()
return results
async def cancel_pending_voices_in_scope(
self,
scope: MessageScope,
) -> tuple[VoiceCancellationResult, ...]:
results = tuple(
{
result.voice_message_id: result
for (entry_scope, _reference_id), result in self._pending_voice.items()
if entry_scope == scope
}.values()
)
for result in results:
self._pending_voice.pop((scope, result.voice_message_id), None)
if result.status_message_id is not None:
self._pending_voice.pop((scope, result.status_message_id), None)
return results
@property
def pending_voice_count(self) -> int:
return len(
{
(result.scope, result.voice_message_id)
for result in self._pending_voice.values()
}
)
class FakeCLISession:
def __init__(self, events: list[dict[str, Any]]) -> None:
self.events = events
self.calls: list[dict[str, Any]] = []
self.is_busy = False
async def start_task(
self, prompt: str, session_id: str | None = None, fork_session: bool = False
) -> AsyncGenerator[dict[str, Any]]:
self.calls.append(
{"prompt": prompt, "session_id": session_id, "fork_session": fork_session}
)
self.is_busy = True
try:
for event in self.events:
await asyncio.sleep(0)
yield event
finally:
self.is_busy = False
class FakeCLIManager:
def __init__(self, event_batches: list[list[dict[str, Any]]] | None = None) -> None:
self.event_batches = event_batches or [default_cli_events("fake_session_1")]
self.sessions: list[FakeCLISession] = []
self.registered: list[tuple[str, str]] = []
self.removed: list[str] = []
self.stopped = False
async def get_or_create_session(
self, session_id: str | None = None
) -> tuple[FakeCLISession, str, bool]:
index = len(self.sessions)
events = self.event_batches[min(index, len(self.event_batches) - 1)]
session = FakeCLISession(events)
self.sessions.append(session)
return session, session_id or f"pending_{index}", session_id is None
async def register_real_session_id(
self, temp_id: str, real_session_id: str
) -> bool:
self.registered.append((temp_id, real_session_id))
return True
async def stop_all(self) -> None:
self.stopped = True
async def remove_session(self, session_id: str) -> bool:
self.removed.append(session_id)
return True
def get_stats(self) -> dict[str, int]:
return {"active_sessions": len(self.sessions), "pending_sessions": 0}
@dataclass(slots=True)
class FakePlatformDriver:
platform_name: str
tmp_path: Path
event_batches: list[list[dict[str, Any]]] | None = None
platform: FakePlatform = field(init=False)
cli_manager: FakeCLIManager = field(init=False)
session_store: SessionStore = field(init=False)
workflow: MessagingWorkflow = field(init=False)
def __post_init__(self) -> None:
self.platform = FakePlatform(self.platform_name)
self.cli_manager = FakeCLIManager(self.event_batches)
self.session_store = SessionStore(
storage_path=str(self.tmp_path / f"{self.platform_name}-sessions.json")
)
self.workflow = MessagingWorkflow(
self.platform,
self.cli_manager,
self.session_store,
platform_name=self.platform_name,
voice_cancellation=self.platform,
)
self.platform.on_message(self.workflow.handle_message)
async def send(
self,
text: str,
*,
chat_id: str = "chat_1",
message_id: str | None = None,
reply_to: str | None = None,
) -> IncomingMessage:
incoming = await self.emit(
text,
chat_id=chat_id,
message_id=message_id,
reply_to=reply_to,
)
await self.wait_for_idle()
return incoming
async def emit(
self,
text: str,
*,
chat_id: str = "chat_1",
message_id: str | None = None,
reply_to: str | None = None,
) -> IncomingMessage:
"""Deliver a message without waiting for background claims to finish."""
incoming = IncomingMessage(
text=text,
chat_id=chat_id,
user_id="user_1",
message_id=message_id or f"in_{uuid.uuid4().hex[:8]}",
platform=self.platform_name,
reply_to_message_id=reply_to,
)
await self.platform.emit(incoming)
return incoming
async def wait_for_idle(self, *, timeout_s: float = 5.0) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
pending = [task for task in self.platform._tasks if not task.done()]
if not pending and await self._all_tree_nodes_terminal():
self.session_store.flush_pending_save()
return
await asyncio.sleep(0.02)
raise AssertionError("fake platform did not become idle")
async def _all_tree_nodes_terminal(self) -> bool:
snapshot = await self.workflow.tree_queue.snapshot()
for tree in snapshot.trees.values():
for node in tree.nodes.values():
if node.get("state") in {"pending", "in_progress"}:
return False
return True
class VoiceFixtureDriver:
@staticmethod
def write_tone_wav(path: Path) -> None:
import math
sample_rate = 16000
duration_s = 0.25
amplitude = 8000
frames = bytearray()
for i in range(int(sample_rate * duration_s)):
sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate))
frames.extend(sample.to_bytes(2, byteorder="little", signed=True))
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(bytes(frames))
def echo_tool_schema() -> dict[str, Any]:
return {
"name": "echo_smoke",
"description": "Echo a test value.",
"input_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
}
def assistant_content_from_events(events: list[SSEEvent]) -> list[dict[str, Any]]:
blocks: dict[int, dict[str, Any]] = {}
block_order: list[int] = []
for event in events:
if event.event == "content_block_start":
index = event_index(event)
block = event.data.get("content_block", {})
if isinstance(block, dict):
blocks[index] = dict(block)
block_order.append(index)
continue
if event.event == "content_block_delta":
index = event_index(event)
block = blocks.get(index)
delta = event.data.get("delta", {})
if not isinstance(block, dict) or not isinstance(delta, dict):
continue
delta_type = delta.get("type")
if delta_type == "text_delta":
block["text"] = str(block.get("text", "")) + str(delta.get("text", ""))
elif delta_type == "thinking_delta":
block["thinking"] = str(block.get("thinking", "")) + str(
delta.get("thinking", "")
)
elif delta_type == "input_json_delta":
block["_partial_json"] = str(block.get("_partial_json", "")) + str(
delta.get("partial_json", "")
)
content: list[dict[str, Any]] = []
for index in block_order:
block = blocks[index]
if block.get("type") == "tool_use":
partial = str(block.pop("_partial_json", ""))
if partial:
try:
block["input"] = json.loads(partial)
except json.JSONDecodeError:
block["input"] = {}
content.append(block)
return content
def tool_use_blocks(events: list[SSEEvent]) -> list[dict[str, Any]]:
return [
block
for block in assistant_content_from_events(events)
if block.get("type") == "tool_use"
]
def default_cli_events(session_id: str) -> list[dict[str, Any]]:
return [
{"type": "session_info", "session_id": session_id},
{
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Inspect the request."},
{
"type": "tool_use",
"id": "toolu_fake",
"name": "Read",
"input": {"file_path": "README.md"},
},
{
"type": "tool_result",
"tool_use_id": "toolu_fake",
"content": "Free Claude Code",
},
{"type": "text", "text": "Fake platform answer."},
]
},
},
{"type": "exit", "code": 0, "stderr": None},
]
def assert_product_stream(events: list[SSEEvent]) -> None:
assert_anthropic_stream_contract(events)
assert text_content(events).strip() or has_tool_use(events), (
"product stream emitted neither text nor tool_use"
)
+69
View File
@@ -0,0 +1,69 @@
"""HTTP helpers for live smoke requests."""
from typing import Any
import httpx
from free_claude_code.core.anthropic.stream_contracts import SSEEvent, parse_sse_lines
from .config import SmokeConfig, auth_headers, redacted
from .server import RunningServer
def message_payload(
text: str,
*,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 128,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": text}],
}
if extra:
payload.update(extra)
return payload
def post_json(
server: RunningServer,
path: str,
payload: dict[str, Any],
config: SmokeConfig,
*,
headers: dict[str, str] | None = None,
) -> httpx.Response:
request_headers = headers or auth_headers()
response = httpx.post(
f"{server.base_url}{path}",
headers=request_headers,
json=payload,
timeout=config.timeout_s,
)
return response
def collect_message_stream(
server: RunningServer,
payload: dict[str, Any],
config: SmokeConfig,
*,
headers: dict[str, str] | None = None,
) -> list[SSEEvent]:
request_headers = headers or auth_headers()
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
headers=request_headers,
json=payload,
timeout=config.timeout_s,
) as response:
if response.status_code != 200:
body = response.read().decode("utf-8", errors="replace")
raise AssertionError(
f"stream request failed: HTTP {response.status_code} "
f"{redacted(body[:1000])}"
)
return parse_sse_lines(response.iter_lines())
+72
View File
@@ -0,0 +1,72 @@
"""Helpers for local-provider smoke availability checks."""
from urllib.parse import urljoin
import httpx
import pytest
from free_claude_code.providers.openai_chat import openai_v1_base_url
LOCAL_PROVIDER_PROBE_TIMEOUT_S = 1.5
_ROOT_OR_V1_PROVIDERS = frozenset({"llamacpp", "ollama"})
def first_local_provider_model_id(
provider: str,
base_url: str,
*,
timeout_s: float,
) -> str:
"""Return the first local model id, or skip when the local server is absent."""
base_url = base_url.strip()
if not base_url:
pytest.skip(f"missing_env: {provider} base URL is not configured")
timeout = min(timeout_s, LOCAL_PROVIDER_PROBE_TIMEOUT_S)
if provider in _ROOT_OR_V1_PROVIDERS:
base_url = openai_v1_base_url(base_url)
return _first_openai_compatible_model_id(
provider,
base_url,
timeout_s=timeout,
)
def _first_openai_compatible_model_id(
provider: str,
base_url: str,
*,
timeout_s: float,
) -> str:
models_url = urljoin(base_url.rstrip("/") + "/", "models")
response = _get_local_provider_response(provider, models_url, timeout_s=timeout_s)
assert response.status_code == 200, response.text
payload = response.json()
data = payload.get("data") if isinstance(payload, dict) else None
if isinstance(data, list):
for item in data:
if isinstance(item, dict) and isinstance(item.get("id"), str):
return item["id"]
pytest.skip(f"missing_env: {provider} local server has no loaded models")
pytest.fail("product_failure: local /models did not expose a model id")
def _get_local_provider_response(
provider: str,
url: str,
*,
timeout_s: float,
) -> httpx.Response:
try:
response = httpx.get(url, timeout=timeout_s)
except httpx.TimeoutException as exc:
pytest.skip(f"missing_env: {provider} local server is not reachable: {exc}")
except httpx.NetworkError as exc:
pytest.skip(f"missing_env: {provider} local server is not running: {exc}")
if response.status_code in {404, 405, 502, 503}:
pytest.skip(
f"missing_env: {provider} local server is not available at {url}: "
f"HTTP {response.status_code}"
)
return response
+105
View File
@@ -0,0 +1,105 @@
"""Small JSON report writer for smoke runs."""
import json
import time
from dataclasses import asdict, dataclass
from .config import SmokeConfig, redacted
@dataclass(slots=True)
class SmokeOutcome:
nodeid: str
outcome: str
classification: str
duration_s: float
markers: list[str]
detail: str
class SmokeReport:
def __init__(self, config: SmokeConfig) -> None:
self.config = config
self.started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
self.outcomes: list[SmokeOutcome] = []
def add(
self,
*,
nodeid: str,
outcome: str,
duration_s: float,
markers: list[str],
detail: str = "",
) -> None:
self.outcomes.append(
SmokeOutcome(
nodeid=nodeid,
outcome=outcome,
classification=classify_outcome(
nodeid=nodeid, outcome=outcome, detail=detail
),
duration_s=duration_s,
markers=markers,
detail=redacted(detail),
)
)
def write(self) -> None:
self.config.results_dir.mkdir(parents=True, exist_ok=True)
path = (
self.config.results_dir
/ f"report-{self.config.worker_id}-{int(time.time())}.json"
)
payload = {
"started_at": self.started_at,
"worker_id": self.config.worker_id,
"targets": sorted(self.config.targets),
"outcomes": [asdict(outcome) for outcome in self.outcomes],
}
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
def classify_outcome(*, nodeid: str, outcome: str, detail: str) -> str:
"""Classify smoke outcomes for triage reports."""
if outcome == "passed":
return "passed"
text = f"{nodeid}\n{detail}".lower()
if outcome == "skipped":
if "smoke target disabled" in text:
return "target_disabled"
if "missing_env" in text:
return "missing_env"
if any(
marker in text
for marker in (
"upstream_unavailable",
"connection refused",
"connecterror",
"readtimeout",
"timed out",
"not reachable",
)
):
return "upstream_unavailable"
return "missing_env"
if "harness_bug" in text:
return "harness_bug"
if "missing_env" in text:
return "missing_env"
if any(
marker in text
for marker in (
"upstream_unavailable",
"connection refused",
"connecterror",
"readtimeout",
"timed out",
"not reachable",
"upstream provider",
)
):
return "upstream_unavailable"
return "product_failure"
+51
View File
@@ -0,0 +1,51 @@
"""Summarize smoke JSON reports for local and workflow triage."""
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class SmokeSummary:
reports: int
outcomes: int
classifications: dict[str, int]
@property
def has_regression(self) -> bool:
return bool(
self.classifications.get("product_failure", 0)
or self.classifications.get("harness_bug", 0)
)
def summarize_reports(results_dir: Path) -> SmokeSummary:
"""Read all report JSON files and count outcome classifications."""
counts: Counter[str] = Counter()
reports = 0
outcomes = 0
for path in sorted(results_dir.glob("report-*.json")):
reports += 1
payload = json.loads(path.read_text(encoding="utf-8"))
for outcome in payload.get("outcomes", []):
if not isinstance(outcome, dict):
continue
outcomes += 1
counts[str(outcome.get("classification") or "unknown")] += 1
return SmokeSummary(
reports=reports,
outcomes=outcomes,
classifications=dict(sorted(counts.items())),
)
def format_summary(summary: SmokeSummary) -> str:
"""Return a compact human-readable summary."""
parts = [
f"reports={summary.reports}",
f"outcomes={summary.outcomes}",
]
parts.extend(f"{name}={count}" for name, count in summary.classifications.items())
status = "regression" if summary.has_regression else "ok"
return f"smoke_summary status={status} " + " ".join(parts)
+118
View File
@@ -0,0 +1,118 @@
"""Subprocess lifecycle helpers for local smoke servers."""
import os
import socket
import subprocess
import time
from collections.abc import Iterable, Iterator
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
import httpx
from .child_process import cmd_free_claude_code_serve
from .config import SmokeConfig, redacted
@dataclass(slots=True)
class RunningServer:
base_url: str
port: int
log_path: Path
process: subprocess.Popen[bytes]
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
@contextmanager
def start_server(
config: SmokeConfig,
*,
env_overrides: dict[str, str] | None = None,
env_unset: Iterable[str] = (),
command: list[str] | None = None,
name: str = "server",
) -> Iterator[RunningServer]:
port = find_free_port()
config.results_dir.mkdir(parents=True, exist_ok=True)
log_path = config.results_dir / f"{name}-{config.worker_id}-{port}.log"
env = os.environ.copy()
for key in env_unset:
env.pop(key, None)
env.update(
{
"HOST": "127.0.0.1",
"PORT": str(port),
"LOG_FILE": str(log_path),
"FCC_OPEN_BROWSER": "0",
"MESSAGING_PLATFORM": "none",
"PYTHONUNBUFFERED": "1",
}
)
if env_overrides:
env.update(env_overrides)
cmd = command or cmd_free_claude_code_serve()
with log_path.open("ab") as log_file:
process = subprocess.Popen(
cmd,
cwd=config.root,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
)
running = RunningServer(
base_url=f"http://127.0.0.1:{port}",
port=port,
log_path=log_path,
process=process,
)
try:
_wait_for_health(running, timeout_s=config.timeout_s)
yield running
finally:
_stop_process(process)
def _wait_for_health(server: RunningServer, *, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
last_error = ""
while time.monotonic() < deadline:
if server.process.poll() is not None:
break
try:
response = httpx.get(f"{server.base_url}/health", timeout=2.0)
if response.status_code == 200:
return
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
except Exception as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(0.25)
log_excerpt = ""
with suppress(OSError):
log_excerpt = server.log_path.read_text(encoding="utf-8", errors="replace")[
-2000:
]
raise AssertionError(
"Smoke server did not become healthy. "
f"last_error={last_error!r} log={redacted(log_excerpt)!r}"
)
def _stop_process(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
+67
View File
@@ -0,0 +1,67 @@
"""Skip helpers for expected live-smoke environment gaps."""
import httpx
import pytest
from free_claude_code.core.anthropic.stream_contracts import SSEEvent, text_content
UPSTREAM_UNAVAILABLE_MARKERS = (
"connection refused",
"connecterror",
"connect timeout",
"readtimeout",
"server disconnected",
"service unavailable",
"temporary failure",
"timed out",
"upstream provider",
)
def is_upstream_unavailable_text(text: str) -> bool:
normalized = text.lower()
return any(marker in normalized for marker in UPSTREAM_UNAVAILABLE_MARKERS)
def skip_upstream_unavailable(reason: str) -> None:
pytest.skip(f"upstream_unavailable: {reason}")
def fail_missing_env(reason: str) -> None:
pytest.fail(f"missing_env: {reason}")
def skip_if_upstream_unavailable_exception(exc: Exception) -> None:
if isinstance(
exc,
(
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.ReadTimeout,
httpx.RemoteProtocolError,
httpx.ProxyError,
),
):
skip_upstream_unavailable(f"{type(exc).__name__}: {exc}")
if is_upstream_unavailable_text(f"{type(exc).__name__}: {exc}"):
skip_upstream_unavailable(f"{type(exc).__name__}: {exc}")
def skip_if_upstream_unavailable_events(events: list[SSEEvent]) -> None:
text = text_content(events)
if is_upstream_unavailable_text(text):
skip_upstream_unavailable(text[:500])
for event in events:
if getattr(event, "event", None) != "error":
continue
data = getattr(event, "data", {})
message = ""
if isinstance(data, dict):
error = data.get("error")
if isinstance(error, dict):
message = str(error.get("message", ""))
else:
message = str(data)
if is_upstream_unavailable_text(message):
skip_upstream_unavailable(message[:500])
+1
View File
@@ -0,0 +1 @@
"""Live prerequisite checks for product smoke."""
+194
View File
@@ -0,0 +1,194 @@
from typing import Any
import httpx
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.http import post_json
from smoke.lib.server import RunningServer
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")]
def test_probe_and_models_routes(
smoke_server: RunningServer, smoke_headers: dict[str, str]
) -> None:
with httpx.Client(base_url=smoke_server.base_url, headers=smoke_headers) as client:
assert client.get("/health").json()["status"] == "healthy"
root = client.get("/")
assert root.status_code == 200
assert root.json()["status"] == "ok"
models = client.get("/v1/models")
assert models.status_code == 200
assert models.json()["data"]
for path in (
"/",
"/health",
"/v1/messages",
"/v1/responses",
"/v1/messages/count_tokens",
):
head = client.head(path)
assert head.status_code == 204, (path, head.status_code, head.text)
options = client.options(path)
assert options.status_code == 204, (path, options.status_code, options.text)
def test_count_tokens_accepts_thinking_tools_and_results(
smoke_server: RunningServer,
smoke_config: SmokeConfig,
smoke_headers: dict[str, str],
) -> None:
payload: dict[str, Any] = {
"model": "claude-3-5-sonnet-20241022",
"messages": [
{"role": "user", "content": "Use the tool."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need to inspect the file."},
{
"type": "tool_use",
"id": "toolu_smoke",
"name": "Read",
"input": {"file_path": "README.md"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_smoke",
"content": "Free Claude Code",
}
],
},
],
"tools": [
{
"name": "Read",
"description": "Read a file",
"input_schema": {
"type": "object",
"properties": {"file_path": {"type": "string"}},
"required": ["file_path"],
},
}
],
}
response = post_json(
smoke_server,
"/v1/messages/count_tokens",
payload,
smoke_config,
headers=smoke_headers,
)
assert response.status_code == 200, response.text
assert response.json()["input_tokens"] > 0
def test_optimization_fast_paths_do_not_need_provider(
smoke_server: RunningServer,
smoke_config: SmokeConfig,
smoke_headers: dict[str, str],
) -> None:
cases: tuple[tuple[str, dict[str, Any], str], ...] = (
(
"quota",
{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1,
"messages": [{"role": "user", "content": "quota"}],
},
"Quota check passed.",
),
(
"title",
{
"model": "claude-3-5-sonnet-20241022",
"system": (
"Generate a concise, sentence-case title (3-7 words). "
'Return JSON with a single "title" field.'
),
"messages": [{"role": "user", "content": "hello"}],
},
"Conversation",
),
(
"prefix",
{
"model": "claude-3-5-sonnet-20241022",
"messages": [
{
"role": "user",
"content": "<policy_spec>extract command</policy_spec>\nCommand: git status --short",
}
],
},
"git",
),
(
"suggestion",
{
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "[SUGGESTION MODE: next]"}],
},
"",
),
(
"filepath",
{
"model": "claude-3-5-sonnet-20241022",
"system": "Extract any file paths that this command output contains.",
"messages": [
{
"role": "user",
"content": "Command: cat smoke/test_api_live.py\nOutput: file contents\n<filepaths>",
}
],
},
"smoke/test_api_live.py",
),
)
for name, payload, expected_text in cases:
response = post_json(
smoke_server, "/v1/messages", payload, smoke_config, headers=smoke_headers
)
assert response.status_code == 200, (name, response.text)
text = response.json()["content"][0]["text"]
assert expected_text in text
def test_invalid_messages_returns_anthropic_error(
smoke_server: RunningServer,
smoke_config: SmokeConfig,
smoke_headers: dict[str, str],
) -> None:
response = post_json(
smoke_server,
"/v1/messages",
{"model": "claude-3-5-sonnet-20241022", "messages": []},
smoke_config,
headers=smoke_headers,
)
assert response.status_code == 400
payload = response.json()
assert payload["type"] == "error"
assert payload["error"]["type"] == "invalid_request_error"
def test_stop_endpoint_reports_no_messaging(
smoke_server: RunningServer, smoke_headers: dict[str, str]
) -> None:
response = httpx.post(
f"{smoke_server.base_url}/stop",
headers=smoke_headers,
timeout=5,
)
assert response.status_code == 503
assert response.json()["detail"] == "Messaging system not initialized"
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
import httpx
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.server import start_server
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("auth")]
def test_auth_token_is_enforced_for_all_supported_header_shapes(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
token = "fcc-smoke-token"
env_file = tmp_path / "auth.env"
env_file.write_text(f'ANTHROPIC_AUTH_TOKEN="{token}"\n', encoding="utf-8")
with start_server(
smoke_config,
env_overrides={
"ANTHROPIC_AUTH_TOKEN": token,
"FCC_ENV_FILE": str(env_file),
"MESSAGING_PLATFORM": "none",
},
name="auth",
) as server:
assert httpx.get(f"{server.base_url}/").status_code == 401
assert (
httpx.get(f"{server.base_url}/", headers={"x-api-key": "wrong"}).status_code
== 401
)
assert (
httpx.get(f"{server.base_url}/", headers={"x-api-key": token}).status_code
== 200
)
assert (
httpx.get(
f"{server.base_url}/",
headers={"authorization": f"Bearer {token}"},
).status_code
== 200
)
assert (
httpx.get(
f"{server.base_url}/",
headers={"anthropic-auth-token": token},
).status_code
== 200
)
+82
View File
@@ -0,0 +1,82 @@
import os
import shutil
from pathlib import Path
import pytest
from free_claude_code.cli.claude_env import build_claude_proxy_env
from smoke.lib.child_process import (
cmd_fcc_init,
cmd_free_claude_code_serve,
run_captured_text,
)
from smoke.lib.config import SmokeConfig
from smoke.lib.server import start_server
from smoke.lib.skips import skip_upstream_unavailable
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("cli")]
def test_fcc_init_scaffolds_user_config(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
env = os.environ.copy()
env["HOME"] = str(tmp_path)
env["USERPROFILE"] = str(tmp_path)
result = run_captured_text(
cmd_fcc_init(),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
assert (tmp_path / ".fcc" / ".env").is_file()
def test_free_claude_code_entrypoint_starts_server(smoke_config: SmokeConfig) -> None:
with start_server(
smoke_config,
command=cmd_free_claude_code_serve(),
env_overrides={"MESSAGING_PLATFORM": "none"},
name="entrypoint",
) as server:
assert server.process.poll() is None
def test_claude_cli_prompt_when_available(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
claude_bin = shutil.which(smoke_config.claude_bin)
if not claude_bin:
pytest.skip(f"Claude CLI not found: {smoke_config.claude_bin}")
models = smoke_config.provider_models()
if not models:
pytest.skip("no configured provider model available for Claude CLI smoke")
with start_server(
smoke_config,
env_overrides={"MODEL": models[0].full_model, "MESSAGING_PLATFORM": "none"},
name="claude-cli",
) as server:
env = build_claude_proxy_env(
proxy_root_url=server.base_url,
auth_token=smoke_config.settings.anthropic_auth_token,
base_env=os.environ,
)
result = run_captured_text(
[claude_bin, "-p", "Reply with exactly FCC_SMOKE_PONG"],
cwd=tmp_path,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
server_log = server.log_path.read_text(encoding="utf-8", errors="replace")
assert result.returncode == 0, result.stderr or result.stdout
assert "POST /v1/messages" in server_log, (
"Claude CLI did not call the local Anthropic-compatible endpoint"
)
if "FCC_SMOKE_PONG" not in result.stdout:
skip_upstream_unavailable(
"Claude CLI reached the local proxy but returned no smoke token"
)
@@ -0,0 +1,47 @@
import pytest
from smoke.lib.config import SmokeConfig, auth_headers
from smoke.lib.http import message_payload, post_json
from smoke.lib.server import RunningServer
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("clients")]
def test_vscode_and_jetbrains_shaped_requests(
smoke_server: RunningServer,
smoke_config: SmokeConfig,
) -> None:
payload = message_payload("quota", max_tokens=1)
vscode_headers = auth_headers()
vscode_headers.update(
{
"anthropic-beta": "messages-2023-12-15",
"user-agent": "Claude-Code-VSCode smoke",
}
)
vscode = post_json(
smoke_server,
"/v1/messages?beta=true",
payload,
smoke_config,
headers=vscode_headers,
)
assert vscode.status_code == 200, vscode.text
assert vscode.json()["content"][0]["text"] == "Quota check passed."
jetbrains_headers = auth_headers()
token = smoke_config.settings.anthropic_auth_token
if token:
jetbrains_headers.pop("x-api-key", None)
jetbrains_headers["authorization"] = f"Bearer {token}"
jetbrains_headers["user-agent"] = "JetBrains-ACP smoke"
jetbrains = post_json(
smoke_server,
"/v1/messages",
payload,
smoke_config,
headers=jetbrains_headers,
)
assert jetbrains.status_code == 200, jetbrains.text
assert jetbrains.json()["content"][0]["text"] == "Quota check passed."
@@ -0,0 +1,34 @@
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.local_providers import first_local_provider_model_id
@pytest.mark.live
@pytest.mark.smoke_target("lmstudio")
def test_lmstudio_models_endpoint_when_available(smoke_config: SmokeConfig) -> None:
first_local_provider_model_id(
"lmstudio",
smoke_config.settings.lm_studio_base_url,
timeout_s=smoke_config.timeout_s,
)
@pytest.mark.live
@pytest.mark.smoke_target("llamacpp")
def test_llamacpp_models_endpoint_when_available(smoke_config: SmokeConfig) -> None:
first_local_provider_model_id(
"llamacpp",
smoke_config.settings.llamacpp_base_url,
timeout_s=smoke_config.timeout_s,
)
@pytest.mark.live
@pytest.mark.smoke_target("ollama")
def test_ollama_models_endpoint_when_available(smoke_config: SmokeConfig) -> None:
first_local_provider_model_id(
"ollama",
smoke_config.settings.ollama_base_url,
timeout_s=smoke_config.timeout_s,
)
+111
View File
@@ -0,0 +1,111 @@
import os
import time
import httpx
import pytest
from smoke.lib.config import SmokeConfig
@pytest.mark.live
@pytest.mark.smoke_target("telegram")
def test_telegram_bot_api_permissions(smoke_config: SmokeConfig) -> None:
token = smoke_config.settings.telegram_bot_token
if not token:
pytest.skip("TELEGRAM_BOT_TOKEN is not configured")
base_url = f"https://api.telegram.org/bot{token}"
get_me = httpx.get(f"{base_url}/getMe", timeout=smoke_config.timeout_s)
assert get_me.status_code == 200, get_me.text
assert get_me.json()["ok"] is True
chat_id = os.getenv("FCC_SMOKE_TELEGRAM_CHAT_ID") or (
smoke_config.settings.allowed_telegram_user_id or ""
)
if not chat_id:
pytest.skip("FCC_SMOKE_TELEGRAM_CHAT_ID or ALLOWED_TELEGRAM_USER_ID required")
marker = f"FCC smoke {int(time.time())}"
sent = httpx.post(
f"{base_url}/sendMessage",
json={"chat_id": chat_id, "text": marker},
timeout=smoke_config.timeout_s,
)
assert sent.status_code == 200, sent.text
message_id = sent.json()["result"]["message_id"]
edited = httpx.post(
f"{base_url}/editMessageText",
json={"chat_id": chat_id, "message_id": message_id, "text": marker + " edit"},
timeout=smoke_config.timeout_s,
)
assert edited.status_code == 200, edited.text
deleted = httpx.post(
f"{base_url}/deleteMessage",
json={"chat_id": chat_id, "message_id": message_id},
timeout=smoke_config.timeout_s,
)
assert deleted.status_code == 200, deleted.text
@pytest.mark.live
@pytest.mark.smoke_target("discord")
def test_discord_bot_api_permissions(smoke_config: SmokeConfig) -> None:
token = smoke_config.settings.discord_bot_token
channel_id = os.getenv("FCC_SMOKE_DISCORD_CHANNEL_ID")
if not channel_id and smoke_config.settings.allowed_discord_channels:
channel_id = smoke_config.settings.allowed_discord_channels.split(",", 1)[0]
if not token:
pytest.skip("DISCORD_BOT_TOKEN is not configured")
if not channel_id:
pytest.skip("FCC_SMOKE_DISCORD_CHANNEL_ID or ALLOWED_DISCORD_CHANNELS required")
headers = {"authorization": f"Bot {token}"}
base_url = "https://discord.com/api/v10"
channel = httpx.get(
f"{base_url}/channels/{channel_id}",
headers=headers,
timeout=smoke_config.timeout_s,
)
assert channel.status_code == 200, channel.text
marker = f"FCC smoke {int(time.time())}"
sent = httpx.post(
f"{base_url}/channels/{channel_id}/messages",
headers=headers,
json={"content": marker},
timeout=smoke_config.timeout_s,
)
assert sent.status_code == 200, sent.text
message_id = sent.json()["id"]
edited = httpx.patch(
f"{base_url}/channels/{channel_id}/messages/{message_id}",
headers=headers,
json={"content": marker + " edit"},
timeout=smoke_config.timeout_s,
)
assert edited.status_code == 200, edited.text
deleted = httpx.delete(
f"{base_url}/channels/{channel_id}/messages/{message_id}",
headers=headers,
timeout=smoke_config.timeout_s,
)
assert deleted.status_code in {200, 204}, deleted.text
@pytest.mark.live
@pytest.mark.smoke_target("telegram")
@pytest.mark.smoke_target("discord")
def test_interactive_inbound_messaging_requires_explicit_mode(
smoke_config: SmokeConfig,
) -> None:
if not smoke_config.interactive:
pytest.skip("set FCC_SMOKE_INTERACTIVE=1 for manual inbound messaging checks")
pytest.skip(
"manual inbound check: start the server, send a nonce from the real client, "
"and verify threaded progress plus /stop, /clear, and /stats"
)
+104
View File
@@ -0,0 +1,104 @@
import time
import httpx
import pytest
from free_claude_code.core.anthropic.stream_contracts import (
assert_anthropic_stream_contract,
text_content,
thinking_content,
)
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.e2e import ProviderMatrixDriver
from smoke.lib.http import collect_message_stream, message_payload
from smoke.lib.server import start_server
from smoke.lib.skips import (
skip_if_upstream_unavailable_events,
skip_if_upstream_unavailable_exception,
)
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")]
def test_model_mapping_configuration_is_consistent(smoke_config: SmokeConfig) -> None:
models = smoke_config.provider_models()
if not models:
pytest.skip("no configured provider models with usable credentials/base URLs")
for provider_model in models:
assert "/" in provider_model.full_model
assert provider_model.model_name
def test_mixed_provider_model_mapping_when_configured(
smoke_config: SmokeConfig,
) -> None:
models = smoke_config.provider_models()
providers = {provider_model.provider for provider_model in models}
if len(providers) < 2:
pytest.skip("configure MODEL_* with at least two provider prefixes")
sources = {provider_model.source for provider_model in models}
assert sources <= {"MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"}
assert len(providers) >= 2
def test_configured_provider_models_stream_successfully(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
try:
with start_server(
smoke_config,
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
name=f"provider-{provider_model.provider}",
) as server:
events = collect_message_stream(
server,
message_payload(smoke_config.prompt, model="fcc-smoke-default"),
smoke_config,
)
skip_if_upstream_unavailable_events(events)
assert_anthropic_stream_contract(events)
has_text = bool(text_content(events).strip())
has_thinking = bool(thinking_content(events).strip())
assert has_text or has_thinking, (
"provider returned no visible text or thinking content"
)
except Exception as exc:
skip_if_upstream_unavailable_exception(exc)
raise AssertionError(
f"{provider_model.source}={provider_model.full_model}: "
f"{type(exc).__name__}: {exc}"
) from exc
@pytest.mark.smoke_target("rate_limit")
def test_client_disconnect_mid_stream_does_not_crash_server(
smoke_config: SmokeConfig,
) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with start_server(
smoke_config,
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
name="disconnect",
) as server:
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
headers=auth_headers(),
json=message_payload(smoke_config.prompt, model="fcc-smoke-default"),
timeout=smoke_config.timeout_s,
) as response:
assert response.status_code == 200, response.read()
for _line in response.iter_lines():
break
time.sleep(0.5)
health = httpx.get(f"{server.base_url}/health", timeout=5)
assert health.status_code == 200
+61
View File
@@ -0,0 +1,61 @@
import pytest
from free_claude_code.core.anthropic.stream_contracts import (
assert_anthropic_stream_contract,
has_tool_use,
)
from smoke.lib.config import SmokeConfig
from smoke.lib.http import collect_message_stream, message_payload
from smoke.lib.server import start_server
from smoke.lib.skips import (
skip_if_upstream_unavailable_events,
skip_if_upstream_unavailable_exception,
)
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("tools")]
def test_live_tool_use_when_configured_model_supports_tools(
smoke_config: SmokeConfig,
) -> None:
models = smoke_config.provider_models()
if not models:
pytest.skip("no configured provider model available for tool-use smoke")
provider_model = models[0]
payload = message_payload(
"Use the echo_smoke tool once with value FCC_SMOKE_TOOL.",
model="fcc-smoke-default",
max_tokens=256,
extra={
"tools": [
{
"name": "echo_smoke",
"description": "Echo a test value.",
"input_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
}
],
"tool_choice": {"type": "tool", "name": "echo_smoke"},
},
)
with start_server(
smoke_config,
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
name="tools",
) as server:
try:
events = collect_message_stream(server, payload, smoke_config)
except Exception as exc:
skip_if_upstream_unavailable_exception(exc)
raise
skip_if_upstream_unavailable_events(events)
assert_anthropic_stream_contract(events)
assert has_tool_use(events), "model did not emit a tool_use block"
+62
View File
@@ -0,0 +1,62 @@
import math
import os
import wave
from pathlib import Path
import pytest
from free_claude_code.messaging.transcription import TranscriptionService
from free_claude_code.messaging.voice import Transcriber
from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber
from smoke.lib.config import SmokeConfig
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("voice")]
@pytest.mark.asyncio
async def test_voice_transcription_backend_when_explicitly_enabled(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
if not smoke_config.settings.voice_note_enabled:
pytest.skip("VOICE_NOTE_ENABLED is false")
if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
pytest.skip("set FCC_SMOKE_RUN_VOICE=1 to run transcription smoke")
wav_path = tmp_path / "smoke-tone.wav"
_write_tone_wav(wav_path)
transcriber: Transcriber
if smoke_config.settings.whisper_device == "nvidia_nim":
transcriber = NvidiaNimTranscriber(
model=smoke_config.settings.whisper_model,
api_key=smoke_config.settings.nvidia_nim_api_key,
)
else:
transcriber = TranscriptionService(
model=smoke_config.settings.whisper_model,
device=smoke_config.settings.whisper_device,
huggingface_api_key=smoke_config.settings.huggingface_api_key,
)
try:
text = await transcriber.transcribe(wav_path)
except ImportError as exc:
pytest.skip(str(exc))
finally:
await transcriber.close()
assert isinstance(text, str)
assert text.strip()
def _write_tone_wav(path: Path) -> None:
sample_rate = 16000
duration_s = 0.25
amplitude = 8000
frames = bytearray()
for i in range(int(sample_rate * duration_s)):
sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate))
frames.extend(sample.to_bytes(2, byteorder="little", signed=True))
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(bytes(frames))
+1
View File
@@ -0,0 +1 @@
"""Product-level end-to-end smoke scenarios."""
+200
View File
@@ -0,0 +1,200 @@
from typing import Any
import httpx
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import (
ConversationDriver,
ProviderMatrixDriver,
SmokeServerDriver,
assert_product_stream,
echo_tool_schema,
)
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")]
def test_api_basic_conversation_e2e(smoke_config: SmokeConfig) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with SmokeServerDriver(
smoke_config,
name="product-api-basic",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
turn = ConversationDriver(server, smoke_config).ask(
"Reply with one short sentence."
)
assert_product_stream(turn.events)
assert turn.text.strip()
def test_api_count_tokens_full_payload_e2e(
smoke_server,
smoke_config: SmokeConfig,
smoke_headers: dict[str, str],
) -> None:
payload: dict[str, Any] = {
"model": "claude-3-5-sonnet-20241022",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Use the image and tool."},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo=",
},
},
],
},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need the tool."},
{"type": "redacted_thinking", "data": "opaque"},
{
"type": "tool_use",
"id": "toolu_smoke",
"name": "echo_smoke",
"input": {"value": "FCC"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_smoke",
"content": "FCC",
}
],
},
],
"tools": [echo_tool_schema()],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
response = httpx.post(
f"{smoke_server.base_url}/v1/messages/count_tokens",
headers=smoke_headers,
json=payload,
timeout=smoke_config.timeout_s,
)
assert response.status_code == 200, response.text
assert response.json()["input_tokens"] > 0
def test_api_request_optimizations_e2e(
smoke_server,
smoke_config: SmokeConfig,
smoke_headers: dict[str, str],
) -> None:
cases: tuple[tuple[str, dict[str, Any], str], ...] = (
(
"quota",
{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1,
"messages": [{"role": "user", "content": "quota"}],
},
"Quota check passed.",
),
(
"title",
{
"model": "claude-3-5-sonnet-20241022",
"system": (
"Generate a concise, sentence-case title (3-7 words). "
'Return JSON with a single "title" field.'
),
"messages": [{"role": "user", "content": "hello"}],
},
"Conversation",
),
(
"prefix",
{
"model": "claude-3-5-sonnet-20241022",
"messages": [
{
"role": "user",
"content": (
"<policy_spec>extract command</policy_spec>\n"
"Command: git status --short"
),
}
],
},
"git",
),
(
"suggestion",
{
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "[SUGGESTION MODE: next]"}],
},
"",
),
(
"filepath",
{
"model": "claude-3-5-sonnet-20241022",
"system": "Extract any file paths that this command output contains.",
"messages": [
{
"role": "user",
"content": (
"Command: cat smoke/product/test_api_product_live.py\n"
"Output: file contents\n<filepaths>"
),
}
],
},
"smoke/product/test_api_product_live.py",
),
)
for name, payload, expected_text in cases:
response = httpx.post(
f"{smoke_server.base_url}/v1/messages",
headers=smoke_headers,
json=payload,
timeout=smoke_config.timeout_s,
)
assert response.status_code == 200, (name, response.text)
text = response.json()["content"][0]["text"]
assert expected_text in text
def test_api_error_shape_e2e(
smoke_server,
smoke_config: SmokeConfig,
smoke_headers: dict[str, str],
) -> None:
response = httpx.post(
f"{smoke_server.base_url}/v1/messages",
headers=smoke_headers,
json={"model": "claude-3-5-sonnet-20241022", "messages": []},
timeout=smoke_config.timeout_s,
)
assert response.status_code == 400
payload = response.json()
assert payload["type"] == "error"
assert payload["error"]["type"] == "invalid_request_error"
def test_api_stop_e2e(smoke_server, smoke_headers: dict[str, str]) -> None:
response = httpx.post(
f"{smoke_server.base_url}/stop",
headers=smoke_headers,
timeout=5,
)
assert response.status_code == 503
assert response.json()["detail"] == "Messaging system not initialized"
+51
View File
@@ -0,0 +1,51 @@
import httpx
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import SmokeServerDriver
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("auth")]
def test_api_auth_header_variants_e2e(smoke_config: SmokeConfig, tmp_path) -> None:
token = "product-smoke-token"
env_file = tmp_path / "auth-product.env"
env_file.write_text(f'ANTHROPIC_AUTH_TOKEN="{token}"\n', encoding="utf-8")
with SmokeServerDriver(
smoke_config,
name="product-auth",
env_overrides={
"ANTHROPIC_AUTH_TOKEN": token,
"FCC_ENV_FILE": str(env_file),
"MESSAGING_PLATFORM": "none",
},
).run() as server:
unauth = httpx.get(
f"{server.base_url}/v1/models", timeout=smoke_config.timeout_s
)
x_api_key = httpx.get(
f"{server.base_url}/v1/models",
headers={"x-api-key": token},
timeout=smoke_config.timeout_s,
)
bearer = httpx.get(
f"{server.base_url}/v1/models",
headers={"authorization": f"Bearer {token}"},
timeout=smoke_config.timeout_s,
)
anthropic = httpx.get(
f"{server.base_url}/v1/models",
headers={"anthropic-auth-token": token},
timeout=smoke_config.timeout_s,
)
invalid = httpx.get(
f"{server.base_url}/v1/models",
headers={"x-api-key": "wrong"},
timeout=smoke_config.timeout_s,
)
assert unauth.status_code == 401
assert x_api_key.status_code == 200
assert bearer.status_code == 200
assert anthropic.status_code == 200
assert invalid.status_code == 401
@@ -0,0 +1,116 @@
import asyncio
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager
from free_claude_code.cli.managed.session import ManagedClaudeSession
from free_claude_code.core.version import package_version
from smoke.lib.child_process import cmd_fcc_init, cmd_fcc_version, run_captured_text
from smoke.lib.config import SmokeConfig
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("cli")]
def test_entrypoint_init_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
env = os.environ.copy()
env["HOME"] = str(tmp_path)
env["USERPROFILE"] = str(tmp_path)
result = run_captured_text(
cmd_fcc_init(),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
env_file = tmp_path / ".fcc" / ".env"
assert env_file.is_file()
assert env_file.read_text(encoding="utf-8").strip()
def test_entrypoint_version_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
env = os.environ.copy()
env["HOME"] = str(tmp_path)
env["USERPROFILE"] = str(tmp_path)
result = run_captured_text(
cmd_fcc_version(),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0
assert result.stdout == f"free-claude-code {package_version()}\n"
assert result.stderr == ""
assert not (tmp_path / ".fcc" / ".env").exists()
@pytest.mark.asyncio
async def test_cli_session_resume_fork_e2e(tmp_path: Path) -> None:
session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082")
process = AsyncMock()
process.stdout.read.side_effect = [b""]
process.stderr.read.return_value = b""
process.wait.return_value = 0
process.returncode = 0
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as spawn:
spawn.return_value = process
async for _event in session.start_task(
"resume this",
session_id="sess_product",
fork_session=True,
):
pass
args = spawn.call_args[0]
assert args[:3] == ("claude", "--resume", "sess_product")
assert "--fork-session" in args
assert "-p" in args
assert "resume this" in args
@pytest.mark.asyncio
async def test_cli_process_cleanup_e2e(tmp_path: Path) -> None:
manager = ManagedClaudeSessionManager(
workspace_path=str(tmp_path),
proxy_root_url="http://127.0.0.1:8082",
)
session, pending_id, is_new = await manager.get_or_create_session()
assert is_new is True
assert pending_id.startswith("pending_")
mocked_stop = AsyncMock(return_value=True)
with patch.object(session, "stop", mocked_stop):
await manager.stop_all()
mocked_stop.assert_awaited_once()
assert manager.get_stats() == {
"active_sessions": 0,
"pending_sessions": 0,
"busy_count": 0,
}
@pytest.mark.asyncio
async def test_cli_session_stop_kills_child_e2e(tmp_path: Path) -> None:
session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082")
process = MagicMock()
process.pid = 123456
process.returncode = None
process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
session.process = process
with patch(
"free_claude_code.cli.managed.session.kill_pid_tree_best_effort"
) as kill_tree:
stopped = await session.stop()
assert stopped is True
kill_tree.assert_called_once_with(process.pid)
process.kill.assert_called_once()
+286
View File
@@ -0,0 +1,286 @@
import json
import os
import shutil
import subprocess
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import (
ClientProtocolDriver,
ConversationDriver,
ProviderMatrixDriver,
SmokeServerDriver,
assert_product_stream,
)
pytestmark = [pytest.mark.live]
@pytest.mark.smoke_target("clients")
def test_vscode_protocol_e2e(smoke_config: SmokeConfig) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with SmokeServerDriver(
smoke_config,
name="product-vscode",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
turn = ConversationDriver(server, smoke_config).stream(
ClientProtocolDriver.adaptive_thinking_payload(),
headers=ClientProtocolDriver.vscode_headers(),
)
assert_product_stream(turn.events)
@pytest.mark.smoke_target("clients")
def test_jetbrains_protocol_e2e(smoke_config: SmokeConfig) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with SmokeServerDriver(
smoke_config,
name="product-jetbrains",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
driver = ConversationDriver(server, smoke_config)
first = driver.stream(
ClientProtocolDriver.tool_result_payload(),
headers=ClientProtocolDriver.jetbrains_headers(smoke_config),
)
assert_product_stream(first.events)
@pytest.mark.smoke_target("clients")
def test_pi_cli_prompt_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
if not shutil.which("pi"):
pytest.skip("missing_env: Pi CLI not found")
uv_bin = shutil.which("uv")
if not uv_bin:
pytest.skip("missing_env: uv not found")
provider_model = ProviderMatrixDriver(smoke_config).first_model()
auth_token = "fcc-pi-smoke-token"
with SmokeServerDriver(
smoke_config,
name="product-pi-cli",
env_overrides={
"MODEL": provider_model.full_model,
"ANTHROPIC_AUTH_TOKEN": auth_token,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
env = os.environ.copy()
env.update(
{
"HOST": "127.0.0.1",
"PORT": str(server.port),
"FCC_OPEN_BROWSER": "0",
"ANTHROPIC_AUTH_TOKEN": auth_token,
"PI_CODING_AGENT_DIR": str(tmp_path / "pi-agent"),
}
)
result = subprocess.run(
[
uv_bin,
"run",
"--project",
str(smoke_config.root),
"--no-sync",
"fcc-pi",
"--no-session",
"--no-approve",
"--print",
"Reply with exactly FCC_SMOKE_PI",
],
cwd=tmp_path,
env=env,
check=False,
capture_output=True,
text=True,
timeout=smoke_config.timeout_s + 15,
)
server_log = server.log_path.read_text(encoding="utf-8", errors="replace")
assert result.returncode == 0, result.stderr or result.stdout
assert "FCC_SMOKE_PI" in result.stdout
assert "POST /v1/messages" in server_log
@pytest.mark.smoke_target("cli")
def test_claude_cli_adaptive_thinking_e2e(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
claude_bin = shutil.which(smoke_config.claude_bin)
if not claude_bin:
pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}")
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with SmokeServerDriver(
smoke_config,
name="product-claude-cli-adaptive",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
result = ClientProtocolDriver.run_claude_prompt(
claude_bin=claude_bin,
server=server,
config=smoke_config,
cwd=tmp_path,
prompt="think hard, then reply with exactly FCC_SMOKE_CLI",
)
server_log = server.log_path.read_text(encoding="utf-8", errors="replace")
assert result.returncode == 0, result.stderr or result.stdout
assert "POST /v1/messages" in server_log
assert " 422 " not in server_log
assert 'HTTP/1.1" 422' not in server_log
assert "400 Bad Request" not in result.stdout
assert "FCC_SMOKE_CLI" in result.stdout
@pytest.mark.smoke_target("cli")
def test_claude_cli_provider_error_e2e(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
claude_bin = shutil.which(smoke_config.claude_bin)
if not claude_bin:
pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}")
broken_model = "lmstudio/fcc-smoke-failing-model"
with (
_deliberately_failing_openai_provider() as (
provider_base_url,
provider_requests,
),
SmokeServerDriver(
smoke_config,
name="product-claude-cli-provider-error",
env_overrides={
"MODEL": broken_model,
"MODEL_OPUS": broken_model,
"MODEL_SONNET": broken_model,
"MODEL_HAIKU": broken_model,
"LM_STUDIO_BASE_URL": provider_base_url,
"MESSAGING_PLATFORM": "none",
},
).run() as server,
):
result = ClientProtocolDriver.run_claude_prompt(
claude_bin=claude_bin,
server=server,
config=smoke_config,
cwd=tmp_path,
prompt="Reply with exactly FCC_SMOKE_UNREACHABLE.",
model="claude-sonnet-4-5-20250929",
)
server_log = server.log_path.read_text(encoding="utf-8", errors="replace")
combined = f"{result.stdout}\n{result.stderr}"
lower = combined.lower()
downstream_requests = sum(
"POST /v1/messages" in line and "HTTP/1.1" in line
for line in server_log.splitlines()
)
assert result.returncode != 0
assert "empty or malformed" not in lower
assert "proxy or gateway intercepting" not in lower
assert "api error" in lower or "selected model" in lower
assert "fcc smoke provider rejected the request deliberately" in lower
assert downstream_requests == 1, server_log
assert provider_requests == ["/v1/chat/completions"]
@contextmanager
def _deliberately_failing_openai_provider() -> Iterator[tuple[str, list[str]]]:
provider_requests: list[str] = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/v1/models":
self._write_json(
HTTPStatus.OK,
{
"object": "list",
"data": [
{
"id": "fcc-smoke-failing-model",
"object": "model",
}
],
},
)
return
if self.path == "/api/v0/models":
self._write_json(HTTPStatus.OK, {"data": []})
return
self._write_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
def do_POST(self) -> None:
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
provider_requests.append(self.path)
self._write_json(
HTTPStatus.BAD_REQUEST,
{
"error": {
"type": "invalid_request_error",
"code": "fcc_smoke_failure",
"message": "FCC smoke provider rejected the request deliberately.",
}
},
)
def log_message(self, format: str, *args: object) -> None:
return
def _write_json(self, status: HTTPStatus, payload: object) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
port = int(server.server_address[1])
yield f"http://127.0.0.1:{port}/v1", provider_requests
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
@pytest.mark.smoke_target("cli")
def test_claude_cli_multiturn_tool_protocol_e2e(smoke_config: SmokeConfig) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
with SmokeServerDriver(
smoke_config,
name="product-claude-cli-protocol",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
turn = ConversationDriver(server, smoke_config).stream(
ClientProtocolDriver.tool_result_payload()
)
assert_product_stream(turn.events)
@@ -0,0 +1,176 @@
import os
import pytest
from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
from free_claude_code.config.settings import Settings
from free_claude_code.messaging.platforms.factory import create_messaging_components
from free_claude_code.providers.runtime import build_provider_config
from smoke.lib.child_process import (
cmd_free_claude_code_serve,
cmd_python_c,
run_captured_text,
)
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import SmokeServerDriver
pytestmark = [pytest.mark.live]
@pytest.mark.smoke_target("config")
def test_env_precedence_e2e(smoke_config: SmokeConfig, tmp_path) -> None:
env_file = tmp_path / "product.env"
env_file.write_text(
'MODEL="open_router/test/model"\nANTHROPIC_AUTH_TOKEN="dotenv-token"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["FCC_ENV_FILE"] = str(env_file)
env["MODEL"] = "nvidia_nim/process-model"
env["ANTHROPIC_AUTH_TOKEN"] = "process-token"
script = (
"from free_claude_code.config.settings import get_settings; "
"s=get_settings(); "
"print(s.model); print(s.anthropic_auth_token)"
)
result = run_captured_text(
cmd_python_c(script),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0, result.stderr
lines = result.stdout.splitlines()
assert lines == ["nvidia_nim/process-model", "dotenv-token"]
@pytest.mark.smoke_target("config")
def test_removed_env_migration_e2e(smoke_config: SmokeConfig, tmp_path) -> None:
env_file = tmp_path / "removed.env"
env_file.write_text('NIM_ENABLE_THINKING="true"\n', encoding="utf-8")
env = os.environ.copy()
env["FCC_ENV_FILE"] = str(env_file)
result = run_captured_text(
cmd_python_c(
"from free_claude_code.config.settings import Settings; Settings()"
),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0, result.stderr
@pytest.mark.smoke_target("config")
def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None:
env_file = tmp_path / "thinking.env"
env_file.write_text(
'ENABLE_MODEL_THINKING="false"\n'
'ENABLE_OPUS_THINKING="true"\n'
"ENABLE_SONNET_THINKING=\n"
'ENABLE_HAIKU_THINKING="false"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["FCC_ENV_FILE"] = str(env_file)
script = (
"from free_claude_code.application.routing import ModelRouter; "
"from free_claude_code.config.settings import Settings; "
"s=Settings(); "
"r=ModelRouter(s); "
"print(r.resolve('claude-opus-4-20250514').thinking_enabled); "
"print(r.resolve('claude-sonnet-4-20250514').thinking_enabled); "
"print(r.resolve('claude-haiku-4-20250514').thinking_enabled); "
"print(r.resolve('unknown-model').thinking_enabled)"
)
result = run_captured_text(
cmd_python_c(script),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == ["True", "False", "False", "False"]
@pytest.mark.smoke_target("config")
def test_proxy_timeout_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None:
env_file = tmp_path / "timeouts.env"
env_file.write_text(
'MODEL="open_router/test/model"\n'
'OPENROUTER_API_KEY="key"\n'
'OPENROUTER_PROXY="socks5://127.0.0.1:9999"\n'
'HTTP_READ_TIMEOUT="321"\n'
'HTTP_CONNECT_TIMEOUT="7"\n'
'HTTP_WRITE_TIMEOUT="8"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["FCC_ENV_FILE"] = str(env_file)
script = (
"from free_claude_code.config.settings import Settings; "
"from free_claude_code.config.provider_catalog import PROVIDER_CATALOG; "
"from free_claude_code.providers.runtime import build_provider_config; "
"s=Settings(); c=build_provider_config(PROVIDER_CATALOG['open_router'], s); "
"print(c.proxy); print(c.http_read_timeout); "
"print(c.http_connect_timeout); print(c.http_write_timeout)"
)
result = run_captured_text(
cmd_python_c(script),
cwd=smoke_config.root,
env=env,
timeout=smoke_config.timeout_s,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == [
"socks5://127.0.0.1:9999",
"321.0",
"7.0",
"8.0",
]
@pytest.mark.smoke_target("extensibility")
def test_provider_runtime_config_e2e() -> None:
settings_kwargs: dict[str, str] = {}
for descriptor in PROVIDER_CATALOG.values():
if descriptor.credential_attr is not None:
settings_kwargs[_settings_init_key(descriptor.credential_attr)] = (
f"{descriptor.provider_id}-key"
)
if descriptor.base_url_attr is not None and descriptor.default_base_url:
settings_kwargs[_settings_init_key(descriptor.base_url_attr)] = (
descriptor.default_base_url
)
settings = Settings.model_validate(settings_kwargs)
for descriptor in PROVIDER_CATALOG.values():
config = build_provider_config(descriptor, settings)
assert config.base_url
assert config.api_key
def _settings_init_key(field_name: str) -> str:
alias = Settings.model_fields[field_name].validation_alias
return alias if isinstance(alias, str) else field_name
@pytest.mark.smoke_target("extensibility")
def test_platform_factory_e2e() -> None:
assert create_messaging_components("not-a-platform") is None
assert create_messaging_components("telegram") is None
assert create_messaging_components("discord") is None
@pytest.mark.smoke_target("cli")
def test_entrypoint_server_e2e(smoke_config: SmokeConfig) -> None:
with SmokeServerDriver(
smoke_config,
name="product-entrypoint",
command=cmd_free_claude_code_serve(),
env_overrides={"MESSAGING_PLATFORM": "none"},
).run() as server:
assert server.process.poll() is None
@@ -0,0 +1,125 @@
import os
import time
import httpx
import pytest
from smoke.lib.config import SmokeConfig
pytestmark = [pytest.mark.live]
@pytest.mark.smoke_target("telegram")
def test_telegram_live_permissions_e2e(smoke_config: SmokeConfig) -> None:
token = smoke_config.settings.telegram_bot_token
if not token:
pytest.skip("missing_env: TELEGRAM_BOT_TOKEN is not configured")
base_url = f"https://api.telegram.org/bot{token}"
get_me = httpx.get(f"{base_url}/getMe", timeout=smoke_config.timeout_s)
assert get_me.status_code == 200, get_me.text
assert get_me.json()["ok"] is True
chat_id = os.getenv("FCC_SMOKE_TELEGRAM_CHAT_ID") or (
smoke_config.settings.allowed_telegram_user_id or ""
)
if not chat_id:
pytest.skip(
"missing_env: FCC_SMOKE_TELEGRAM_CHAT_ID or "
"ALLOWED_TELEGRAM_USER_ID required"
)
marker = f"FCC product smoke {int(time.time())}"
sent = httpx.post(
f"{base_url}/sendMessage",
json={"chat_id": chat_id, "text": marker},
timeout=smoke_config.timeout_s,
)
assert sent.status_code == 200, sent.text
message_id = sent.json()["result"]["message_id"]
edited = httpx.post(
f"{base_url}/editMessageText",
json={"chat_id": chat_id, "message_id": message_id, "text": marker + " edit"},
timeout=smoke_config.timeout_s,
)
assert edited.status_code == 200, edited.text
deleted = httpx.post(
f"{base_url}/deleteMessage",
json={"chat_id": chat_id, "message_id": message_id},
timeout=smoke_config.timeout_s,
)
assert deleted.status_code == 200, deleted.text
@pytest.mark.smoke_target("discord")
def test_discord_live_permissions_e2e(smoke_config: SmokeConfig) -> None:
token = smoke_config.settings.discord_bot_token
channel_id = os.getenv("FCC_SMOKE_DISCORD_CHANNEL_ID")
if not channel_id and smoke_config.settings.allowed_discord_channels:
channel_id = smoke_config.settings.allowed_discord_channels.split(",", 1)[0]
if not token:
pytest.skip("missing_env: DISCORD_BOT_TOKEN is not configured")
if not channel_id:
pytest.skip(
"missing_env: FCC_SMOKE_DISCORD_CHANNEL_ID or "
"ALLOWED_DISCORD_CHANNELS required"
)
headers = {"authorization": f"Bot {token}"}
base_url = "https://discord.com/api/v10"
channel = httpx.get(
f"{base_url}/channels/{channel_id}",
headers=headers,
timeout=smoke_config.timeout_s,
)
assert channel.status_code == 200, channel.text
marker = f"FCC product smoke {int(time.time())}"
sent = httpx.post(
f"{base_url}/channels/{channel_id}/messages",
headers=headers,
json={"content": marker},
timeout=smoke_config.timeout_s,
)
assert sent.status_code == 200, sent.text
message_id = sent.json()["id"]
edited = httpx.patch(
f"{base_url}/channels/{channel_id}/messages/{message_id}",
headers=headers,
json={"content": marker + " edit"},
timeout=smoke_config.timeout_s,
)
assert edited.status_code == 200, edited.text
deleted = httpx.delete(
f"{base_url}/channels/{channel_id}/messages/{message_id}",
headers=headers,
timeout=smoke_config.timeout_s,
)
assert deleted.status_code in {200, 204}, deleted.text
@pytest.mark.smoke_target("telegram")
def test_telegram_live_manual_inbound_e2e(smoke_config: SmokeConfig) -> None:
if not smoke_config.interactive:
pytest.skip("missing_env: set FCC_SMOKE_INTERACTIVE=1 for manual inbound smoke")
pytest.skip(
"manual product smoke: send the printed nonce to Telegram and verify "
"progress edits, final transcript, /stop, /clear, /stats, and voice note "
"behavior from the real client"
)
@pytest.mark.smoke_target("discord")
def test_discord_live_manual_inbound_e2e(smoke_config: SmokeConfig) -> None:
if not smoke_config.interactive:
pytest.skip("missing_env: set FCC_SMOKE_INTERACTIVE=1 for manual inbound smoke")
pytest.skip(
"manual product smoke: send the printed nonce to Discord and verify "
"progress edits, final transcript, /stop, /clear, /stats, and voice note "
"behavior from the real client"
)
@@ -0,0 +1,58 @@
import pytest
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import ConversationDriver, SmokeServerDriver, assert_product_stream
from smoke.lib.local_providers import first_local_provider_model_id
pytestmark = [pytest.mark.live]
@pytest.mark.smoke_target("lmstudio")
def test_lmstudio_messages_e2e(smoke_config: SmokeConfig) -> None:
_local_provider_messages_e2e(
smoke_config,
provider="lmstudio",
base_url=smoke_config.settings.lm_studio_base_url,
)
@pytest.mark.smoke_target("llamacpp")
def test_llamacpp_openai_chat_e2e(smoke_config: SmokeConfig) -> None:
_local_provider_messages_e2e(
smoke_config,
provider="llamacpp",
base_url=smoke_config.settings.llamacpp_base_url,
)
@pytest.mark.smoke_target("ollama")
def test_ollama_openai_chat_e2e(smoke_config: SmokeConfig) -> None:
_local_provider_messages_e2e(
smoke_config,
provider="ollama",
base_url=smoke_config.settings.ollama_base_url,
)
def _local_provider_messages_e2e(
smoke_config: SmokeConfig,
*,
provider: str,
base_url: str,
) -> None:
model_id = first_local_provider_model_id(
provider,
base_url,
timeout_s=smoke_config.timeout_s,
)
with SmokeServerDriver(
smoke_config,
name=f"product-{provider}-messages",
env_overrides={"MODEL": f"{provider}/{model_id}", "MESSAGING_PLATFORM": "none"},
).run() as server:
turn = ConversationDriver(server, smoke_config).ask(
"Reply with one short sentence."
)
assert_product_stream(turn.events)
@@ -0,0 +1,484 @@
import asyncio
import json
import pytest
from free_claude_code.messaging.models import MessageScope
from free_claude_code.messaging.platforms.ports import MessagingStartupNotice
from free_claude_code.messaging.trees import MessageState, TreeIdentity
from smoke.lib.e2e import FakeCLISession, FakePlatformDriver, default_cli_events
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("messaging")]
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_messaging_fake_full_flow_e2e(platform_name: str, tmp_path) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
incoming = await driver.send("Please inspect README.", message_id="root_1")
node = await driver.workflow.tree_queue.get_node(
incoming.scope,
incoming.message_id,
)
assert node is not None
assert node.state == MessageState.COMPLETED
assert driver.platform.sent
assert driver.platform.edits
edit_text = "\n".join(edit["text"] for edit in driver.platform.edits)
assert "Fake platform answer" in edit_text
assert "Read" in edit_text
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_messaging_subagent_control_e2e(platform_name: str, tmp_path) -> None:
task_events = [
{"type": "session_info", "session_id": "sess_task"},
{
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Need a focused worker."},
{
"type": "tool_use",
"id": "toolu_task",
"name": "Task",
"input": {"description": "inspect", "prompt": "inspect"},
},
{"type": "text", "text": "Subagent result rendered."},
]
},
},
{"type": "exit", "code": 0, "stderr": None},
]
driver = FakePlatformDriver(platform_name, tmp_path, event_batches=[task_events])
await driver.send("Delegate this safely.", message_id="root_task")
edit_text = "\n".join(edit["text"] for edit in driver.platform.edits)
assert "Subagent" in edit_text
assert "Tool calls" in edit_text
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_messaging_commands_stop_clear_stats_e2e(
platform_name: str, tmp_path
) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
root = await driver.send("start work", message_id="root_1")
root_status_id = driver.platform.sent[-1]["message_id"]
await driver.send("/stats", message_id="stats_1")
await driver.send("/stop", message_id="stop_1", reply_to=root.message_id)
await driver.send("/clear", message_id="clear_1", reply_to=root.message_id)
global_prompt = await driver.send("clear globally", message_id="global_prompt")
global_status_id = driver.platform.sent[-1]["message_id"]
await driver.send("/clear", message_id="clear_all")
sent_text = "\n".join(sent["text"] for sent in driver.platform.sent)
deleted = {entry["message_id"] for entry in driver.platform.deletes}
assert "Stats" in sent_text
assert "Nothing to stop for that message" in sent_text
assert {
root.message_id,
root_status_id,
global_prompt.message_id,
global_status_id,
"clear_1",
"clear_all",
} <= deleted
assert driver.session_store.load_conversation_snapshot().trees == {}
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_reply_clear_uses_literal_platform_subtree_e2e(
platform_name: str,
tmp_path,
) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
root = await driver.send("root", message_id="root")
root_status = driver.platform.sent[-1]["message_id"]
sibling = await driver.send(
"prompt sibling",
message_id="sibling",
reply_to=root.message_id,
)
sibling_status = driver.platform.sent[-1]["message_id"]
descendant = await driver.send(
"status descendant",
message_id="descendant",
reply_to=root_status,
)
descendant_status = driver.platform.sent[-1]["message_id"]
await driver.send(
"/clear",
message_id="clear-status",
reply_to=root_status,
)
deleted = {entry["message_id"] for entry in driver.platform.deletes}
assert {
root_status,
descendant.message_id,
descendant_status,
"clear-status",
} <= deleted
assert {root.message_id, sibling.message_id, sibling_status}.isdisjoint(deleted)
root_view = await driver.workflow.tree_queue.get_node(root.scope, root.message_id)
sibling_view = await driver.workflow.tree_queue.get_node(
sibling.scope, sibling.message_id
)
assert root_view is not None and root_view.state is MessageState.ERROR
assert root_view.session_id is None
assert sibling_view is not None and sibling_view.state is MessageState.COMPLETED
assert (
await driver.workflow.tree_queue.get_node(
descendant.scope, descendant.message_id
)
is None
)
await driver.send(
"fresh continuation",
message_id="fresh",
reply_to=root.message_id,
)
fresh_call = next(
call
for session in driver.cli_manager.sessions
for call in session.calls
if call["prompt"] == "fresh continuation"
)
assert fresh_call["session_id"] is None
@pytest.mark.asyncio
async def test_messaging_startup_notice_is_clearable_e2e(tmp_path) -> None:
first_driver = FakePlatformDriver("telegram", tmp_path)
scope = MessageScope(platform="telegram", chat_id="chat_1")
await first_driver.workflow.publish_startup_notice(
MessagingStartupNotice(
chat_id=scope.chat_id,
transport_label="Bot API",
)
)
first_startup_id = first_driver.platform.sent[-1]["message_id"]
first_driver.session_store.flush_pending_save()
driver = FakePlatformDriver("telegram", tmp_path)
await driver.platform.send_message("untracked", "advance fake message ID")
await driver.workflow.publish_startup_notice(
MessagingStartupNotice(
chat_id=scope.chat_id,
transport_label="Bot API",
)
)
second_startup = driver.platform.sent[-1]
second_startup_id = second_startup["message_id"]
assert second_startup["text"] == (
"🚀 *Claude Code Proxy is online\\!* \\(Bot API\\)"
)
assert second_startup["parse_mode"] == "MarkdownV2"
assert driver.session_store.get_tracked_message_ids_for_chat(
scope.platform, scope.chat_id
) == [first_startup_id, second_startup_id]
await driver.send("/clear", message_id="clear_startup")
deleted = {entry["message_id"] for entry in driver.platform.deletes}
assert {first_startup_id, second_startup_id, "clear_startup"} <= deleted
assert (
driver.session_store.get_tracked_message_ids_for_chat(
scope.platform, scope.chat_id
)
== []
)
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_messaging_active_stop_uses_status_only_e2e(
platform_name: str,
tmp_path,
monkeypatch,
) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
started = asyncio.Event()
class GatedActiveSession(FakeCLISession):
async def start_task(
self,
prompt: str,
session_id: str | None = None,
fork_session: bool = False,
):
self.calls.append(
{
"prompt": prompt,
"session_id": session_id,
"fork_session": fork_session,
}
)
self.is_busy = True
try:
yield {
"type": "assistant",
"message": {
"content": [{"type": "text", "text": "partial answer"}]
},
}
started.set()
await asyncio.Event().wait()
finally:
self.is_busy = False
async def controlled_session(session_id: str | None = None):
session = GatedActiveSession([])
driver.cli_manager.sessions.append(session)
return session, session_id or "pending_0", session_id is None
monkeypatch.setattr(
driver.cli_manager,
"get_or_create_session",
controlled_session,
)
root = await driver.emit("active work", message_id="root_active")
await started.wait()
status_id = driver.platform.sent[-1]["message_id"]
sent_before_stop = len(driver.platform.sent)
await driver.emit(
"/stop",
message_id="stop_active",
reply_to=root.message_id,
)
await driver.wait_for_idle()
assert len(driver.platform.sent) == sent_before_stop
stopped_edits = [
edit
for edit in driver.platform.edits
if edit["message_id"] == status_id and "Stopped" in edit["text"]
]
assert stopped_edits
assert "partial answer" in stopped_edits[-1]["text"]
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_tree_threading_e2e(platform_name: str, tmp_path) -> None:
batches = [default_cli_events("sess_root"), default_cli_events("sess_branch")]
driver = FakePlatformDriver(platform_name, tmp_path, event_batches=batches)
root = await driver.send("root prompt", message_id="root_1")
branch = await driver.send(
"branch prompt", message_id="branch_1", reply_to=root.message_id
)
branch_node = await driver.workflow.tree_queue.get_node(
branch.scope,
branch.message_id,
)
assert branch_node is not None
assert branch_node.parent_id == root.message_id
assert driver.cli_manager.sessions[1].calls[0]["session_id"] == "sess_root"
assert driver.cli_manager.sessions[1].calls[0]["fork_session"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_messaging_queued_scoped_cancel_e2e(
platform_name: str,
tmp_path,
monkeypatch,
) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
root_started = asyncio.Event()
release_root = asyncio.Event()
class GatedRootSession(FakeCLISession):
async def start_task(
self,
prompt: str,
session_id: str | None = None,
fork_session: bool = False,
):
self.calls.append(
{
"prompt": prompt,
"session_id": session_id,
"fork_session": fork_session,
}
)
self.is_busy = True
root_started.set()
try:
await release_root.wait()
for event in self.events:
await asyncio.sleep(0)
yield event
finally:
self.is_busy = False
async def controlled_session(session_id: str | None = None):
index = len(driver.cli_manager.sessions)
events = default_cli_events(f"sess_{index}")
session = GatedRootSession(events) if index == 0 else FakeCLISession(events)
driver.cli_manager.sessions.append(session)
return session, session_id or f"pending_{index}", session_id is None
monkeypatch.setattr(
driver.cli_manager,
"get_or_create_session",
controlled_session,
)
root = await driver.emit("root", message_id="root")
await root_started.wait()
cancelled = await driver.emit(
"cancel me",
message_id="cancelled",
reply_to=root.message_id,
)
cancelled_status_id = driver.platform.sent[-1]["message_id"]
survivor = await driver.emit(
"run me",
message_id="survivor",
reply_to=root.message_id,
)
sent_before_stop = len(driver.platform.sent)
await driver.emit(
"/stop",
message_id="stop-cancelled",
reply_to=cancelled.message_id,
)
assert len(driver.platform.sent) == sent_before_stop
release_root.set()
await driver.wait_for_idle()
prompts = [
call["prompt"]
for session in driver.cli_manager.sessions
for call in session.calls
]
assert prompts == ["root", "run me"]
cancelled_view = await driver.workflow.tree_queue.get_node(
cancelled.scope,
cancelled.message_id,
)
survivor_view = await driver.workflow.tree_queue.get_node(
survivor.scope,
survivor.message_id,
)
assert cancelled_view is not None
assert cancelled_view.state is MessageState.ERROR
assert survivor_view is not None
assert survivor_view.state is MessageState.COMPLETED
rendered = "\n".join(
entry["text"] for entry in driver.platform.sent + driver.platform.edits
)
assert "position 1" in rendered
assert "position 2" in rendered
assert "Stopped" in rendered
assert any(
edit["message_id"] == cancelled_status_id and "Stopped" in edit["text"]
for edit in driver.platform.edits
)
driver.session_store.flush_pending_save()
persisted = driver.session_store.load_conversation_snapshot()
root_identity = TreeIdentity(scope=root.scope, root_id=root.message_id)
assert persisted.trees[root_identity].nodes["survivor"]["state"] == "completed"
@pytest.mark.asyncio
async def test_restart_restore_and_session_persistence_e2e(tmp_path) -> None:
first = FakePlatformDriver("telegram", tmp_path)
root = await first.send("persist me", message_id="root_1")
first.session_store.flush_pending_save()
session_file = tmp_path / "telegram-sessions.json"
payload = json.loads(session_file.read_text(encoding="utf-8"))
assert payload["conversation"]["trees"]
assert payload["managed_messages"]
restored = FakePlatformDriver("telegram", tmp_path)
restored.platform.continue_message_sequence_after(first.platform)
restored.workflow.restore()
saved = restored.session_store.load_conversation_snapshot()
assert saved.trees
identity = TreeIdentity(scope=root.scope, root_id=root.message_id)
assert saved.get_tree(identity) is not None
reply = await restored.send(
"continue from disk",
message_id="reply_1",
reply_to=root.message_id,
)
reply_view = await restored.workflow.tree_queue.get_node(
reply.scope,
reply.message_id,
)
assert reply_view is not None
assert reply_view.parent_id == root.message_id
call = restored.cli_manager.sessions[0].calls[0]
assert call["session_id"] == "fake_session_1"
assert call["fork_session"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_same_message_ids_are_isolated_by_chat_e2e(
platform_name: str,
tmp_path,
) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
first = await driver.send("first chat", chat_id="chat_a", message_id="42")
second = await driver.send("second chat", chat_id="chat_b", message_id="42")
snapshot = await driver.workflow.tree_queue.snapshot()
assert set(snapshot.trees) == {
TreeIdentity(scope=first.scope, root_id="42"),
TreeIdentity(scope=second.scope, root_id="42"),
}
reply = await driver.send(
"first chat reply",
chat_id="chat_a",
message_id="43",
reply_to="42",
)
reply_view = await driver.workflow.tree_queue.get_node(reply.scope, "43")
assert reply_view is not None
assert reply_view.parent_id == "42"
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["discord", "telegram"])
async def test_voice_platform_fake_e2e(platform_name: str, tmp_path) -> None:
driver = FakePlatformDriver(platform_name, tmp_path)
driver.platform.seed_pending_voice("chat_1", "voice_msg_1", "voice_status_1")
await driver.send("/clear", message_id="clear_voice", reply_to="voice_msg_1")
driver.platform.seed_pending_voice("chat_1", "voice_msg_2", "voice_status_2")
sent_before_stop = len(driver.platform.sent)
await driver.send("/stop", message_id="stop_voice")
deleted = {entry["message_id"] for entry in driver.platform.deletes}
assert {"voice_msg_1", "voice_status_1", "clear_voice"} <= deleted
assert driver.platform.pending_voice_count == 0
sent_text = "\n".join(sent["text"] for sent in driver.platform.sent)
assert "Voice note cancelled" not in sent_text
assert len(driver.platform.sent) == sent_before_stop
assert any(
edit["message_id"] == "voice_status_2" and "Stopped" in edit["text"]
for edit in driver.platform.edits
)
@@ -0,0 +1,68 @@
import shutil
from pathlib import Path
import pytest
from smoke.lib.claude_cli_matrix import (
CliMatrixOutcome,
regression_failures,
run_cli_feature_probes,
write_matrix_report,
)
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import SmokeServerDriver
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("nvidia_nim_cli")]
def test_nvidia_nim_cli_matrix_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
if not smoke_config.has_provider_configuration("nvidia_nim"):
pytest.skip("missing_env: NVIDIA_NIM_API_KEY is not configured")
claude_bin = shutil.which(smoke_config.claude_bin)
if not claude_bin:
pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}")
provider_models = smoke_config.nvidia_nim_cli_models()
if not provider_models:
pytest.skip("missing_env: no NVIDIA NIM CLI smoke models configured")
outcomes: list[CliMatrixOutcome] = []
for provider_model in provider_models:
with SmokeServerDriver(
smoke_config,
name=f"product-nvidia-nim-cli-{_slug(provider_model.model_name)}",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
"ENABLE_MODEL_THINKING": "true",
"LOG_RAW_API_PAYLOADS": "true",
"LOG_RAW_SSE_EVENTS": "true",
},
).run() as server:
outcomes.extend(
run_cli_feature_probes(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
model_dir=tmp_path / _slug(provider_model.model_name),
marker_prefix="NIM",
)
)
report_path = write_matrix_report(
smoke_config,
outcomes,
target="nvidia_nim_cli",
filename_prefix="nvidia-nim-cli",
)
failures = regression_failures(outcomes)
assert not failures, (
f"NVIDIA NIM CLI matrix regressions written to {report_path}:\n"
+ "\n".join(failures)
)
def _slug(value: str) -> str:
return "".join(char if char.isalnum() else "-" for char in value).strip("-")
@@ -0,0 +1,70 @@
import shutil
from pathlib import Path
import pytest
from smoke.lib.claude_cli_matrix import (
CliMatrixOutcome,
regression_failures,
run_cli_feature_probes,
write_matrix_report,
)
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import SmokeServerDriver
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("openrouter_free_cli")]
def test_openrouter_free_cli_matrix_e2e(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
if not smoke_config.has_provider_configuration("open_router"):
pytest.skip("missing_env: OPENROUTER_API_KEY is not configured")
claude_bin = shutil.which(smoke_config.claude_bin)
if not claude_bin:
pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}")
provider_models = smoke_config.openrouter_free_cli_models()
if not provider_models:
pytest.skip("missing_env: no OpenRouter free CLI smoke models configured")
outcomes: list[CliMatrixOutcome] = []
for provider_model in provider_models:
with SmokeServerDriver(
smoke_config,
name=f"product-openrouter-free-cli-{_slug(provider_model.model_name)}",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
"ENABLE_MODEL_THINKING": "true",
"LOG_RAW_API_PAYLOADS": "true",
"LOG_RAW_SSE_EVENTS": "true",
},
).run() as server:
outcomes.extend(
run_cli_feature_probes(
claude_bin=claude_bin,
server=server,
smoke_config=smoke_config,
provider_model=provider_model,
model_dir=tmp_path / _slug(provider_model.model_name),
marker_prefix="OPENROUTER_FREE",
)
)
report_path = write_matrix_report(
smoke_config,
outcomes,
target="openrouter_free_cli",
filename_prefix="openrouter-free-cli",
)
failures = regression_failures(outcomes)
assert not failures, (
f"OpenRouter free CLI matrix regressions written to {report_path}:\n"
+ "\n".join(failures)
)
def _slug(value: str) -> str:
return "".join(char if char.isalnum() else "-" for char in value).strip("-")
+583
View File
@@ -0,0 +1,583 @@
from typing import Any
import httpx
import pytest
from free_claude_code.application.routing import ModelRouter
from free_claude_code.core.anthropic.stream_contracts import (
SSEEvent,
parse_sse_lines,
)
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.e2e import (
ConversationDriver,
ProviderMatrixDriver,
SmokeServerDriver,
assert_product_stream,
echo_tool_schema,
tool_use_blocks,
)
from smoke.lib.skips import (
skip_if_upstream_unavailable_events,
skip_if_upstream_unavailable_exception,
)
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")]
def test_provider_matrix_presence_e2e(smoke_config: SmokeConfig) -> None:
models = ProviderMatrixDriver(smoke_config).provider_smoke_models()
assert models or smoke_config.provider_matrix == frozenset()
def test_model_mapping_matrix_e2e(smoke_config: SmokeConfig) -> None:
models = ProviderMatrixDriver(smoke_config).configured_models()
sources = {model.source for model in models}
assert sources <= {"MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"}
for model in models:
assert model.provider
assert model.model_name
def test_provider_text_multiturn_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
_run_provider_scenario(smoke_config, provider_model, _scenario_text_multiturn)
def test_provider_adaptive_thinking_history_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
_run_provider_scenario(
smoke_config, provider_model, _scenario_adaptive_thinking_history
)
def test_provider_interleaved_thinking_tool_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
_run_provider_scenario(smoke_config, provider_model, _scenario_interleaved_history)
@pytest.mark.smoke_target("tools")
def test_provider_tool_use_then_text_history_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
"""OpenAI-compatible path: history with tool_use + assistant text after tool (issue #206)."""
_run_provider_scenario(
smoke_config, provider_model, _scenario_tool_use_then_text_in_history
)
@pytest.mark.smoke_target("tools")
def test_provider_tool_result_continuation_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
_run_provider_scenario(
smoke_config, provider_model, _scenario_tool_result_continuation
)
@pytest.mark.smoke_target("tools")
def test_gemini_thought_signature_tool_continuation_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
if provider_model.provider != "gemini":
pytest.skip("gemini-specific smoke scenario")
_run_provider_scenario(
smoke_config,
provider_model,
_scenario_gemini_thought_signature_tool_continuation,
)
@pytest.mark.smoke_target("tools")
def test_provider_reasoning_tool_continuation_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
if not _provider_smoke_thinking_enabled(smoke_config):
pytest.skip("the configured Claude route does not enable thinking")
_run_provider_scenario(
smoke_config, provider_model, _scenario_reasoning_tool_continuation
)
def test_mistral_native_reasoning_model_e2e(smoke_config: SmokeConfig) -> None:
provider_model = smoke_config.mistral_reasoning_smoke_model()
if provider_model is None:
pytest.skip("missing_env: mistral is not configured")
payload = {
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Reply with one short sentence."}],
"thinking": {"type": "adaptive"},
}
with _server_for_provider(
smoke_config, provider_model, "mistral-native-reasoning"
) as server:
turn = ConversationDriver(server, smoke_config).stream(payload)
_assert_provider_product_stream(turn.events)
event_text = "\n".join(event.raw for event in turn.events)
assert "thinking_delta" in event_text, (
f"{provider_model.source}={provider_model.full_model} completed without "
"native Mistral thinking output"
)
@pytest.mark.smoke_target("rate_limit")
def test_provider_disconnect_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
_run_provider_scenario(smoke_config, provider_model, _scenario_disconnect)
def test_provider_error_e2e(smoke_config: SmokeConfig) -> None:
provider_model = ProviderMatrixDriver(smoke_config).first_model()
broken_model = f"{provider_model.provider}/fcc-smoke-missing-model"
with (
SmokeServerDriver(
smoke_config,
name=f"product-provider-error-{provider_model.provider}",
env_overrides={"MODEL": broken_model, "MESSAGING_PLATFORM": "none"},
).run() as server,
httpx.Client(timeout=smoke_config.timeout_s) as client,
):
response = client.post(
f"{server.base_url}/v1/messages",
headers=auth_headers(),
json={
"model": "fcc-smoke-default",
"max_tokens": 32,
"messages": [{"role": "user", "content": "hello"}],
},
)
assert response.status_code >= 400
assert response.headers["content-type"].startswith("application/json")
assert response.headers["x-should-retry"] == "false"
payload = response.json()
assert payload["type"] == "error"
assert payload["error"]["type"]
assert payload["error"]["message"]
assert payload["request_id"] == response.headers["request-id"]
def test_provider_codex_responses_text_e2e(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
try:
with (
_server_for_provider(
smoke_config, provider_model, "codex-responses"
) as server,
httpx.stream(
"POST",
f"{server.base_url}/v1/responses",
headers=_openai_auth_headers(smoke_config),
json={
"model": provider_model.full_model,
"input": smoke_config.prompt,
"max_output_tokens": 128,
"stream": True,
},
timeout=smoke_config.timeout_s,
) as response,
):
assert response.status_code == 200, response.read()
events = parse_sse_lines(response.iter_lines())
except Exception as exc:
skip_if_upstream_unavailable_exception(exc)
raise
skip_if_upstream_unavailable_events(events)
names = [event.event for event in events]
assert names[0] == "response.created", names
assert names[-1] == "response.completed", names
assert any(event.event == "response.output_text.delta" for event in events), names
def test_openrouter_native_e2e(smoke_config: SmokeConfig) -> None:
models = [
model
for model in ProviderMatrixDriver(smoke_config).provider_smoke_models()
if model.provider == "open_router"
]
if not models:
pytest.skip("missing_env: open_router is not configured")
provider_model = models[0]
with SmokeServerDriver(
smoke_config,
name="product-openrouter-native",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
turn = ConversationDriver(server, smoke_config).stream(
{
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [
{
"role": "user",
"content": "Reply with one short sentence.",
}
],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
)
_assert_provider_product_stream(turn.events)
def _run_provider_scenario(
smoke_config: SmokeConfig,
provider_model: ProviderModel,
scenario,
) -> None:
try:
scenario(smoke_config, provider_model)
except Exception as exc:
skip_if_upstream_unavailable_exception(exc)
raise AssertionError(
f"{provider_model.source}={provider_model.full_model}: "
f"{type(exc).__name__}: {exc}"
) from exc
def _assert_provider_product_stream(events: list[SSEEvent]) -> None:
skip_if_upstream_unavailable_events(events)
assert_product_stream(events)
def _tool_use_blocks_or_skip(
events: list[SSEEvent], message: str
) -> list[dict[str, Any]]:
skip_if_upstream_unavailable_events(events)
blocks = tool_use_blocks(events)
assert blocks, message
return blocks
def _provider_smoke_thinking_enabled(smoke_config: SmokeConfig) -> bool:
return (
ModelRouter(smoke_config.settings)
.resolve("claude-sonnet-4-5-20250929")
.thinking_enabled
)
def _scenario_text_multiturn(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
with _server_for_provider(smoke_config, provider_model, "text") as server:
driver = ConversationDriver(server, smoke_config)
first = driver.ask("Reply with one short sentence.")
second = driver.ask("Reply with a different short sentence.")
_assert_provider_product_stream(first.events)
_assert_provider_product_stream(second.events)
def _scenario_adaptive_thinking_history(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
payload = {
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "unsigned hidden thought"},
{"type": "redacted_thinking", "data": "opaque"},
{"type": "text", "text": "Hello."},
],
},
{"role": "user", "content": "Reply with one short sentence."},
],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
with _server_for_provider(smoke_config, provider_model, "adaptive") as server:
turn = ConversationDriver(server, smoke_config).stream(payload)
_assert_provider_product_stream(turn.events)
def _scenario_interleaved_history(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Use the tool."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need to inspect first."},
{"type": "text", "text": "I will call the tool."},
{
"type": "tool_use",
"id": "toolu_interleaved",
"name": "echo_smoke",
"input": {"value": "FCC_INTERLEAVED"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_interleaved",
"content": "FCC_INTERLEAVED",
}
],
},
],
"tools": [echo_tool_schema()],
"thinking": {"type": "adaptive"},
}
with _server_for_provider(smoke_config, provider_model, "interleaved") as server:
turn = ConversationDriver(server, smoke_config).stream(payload)
_assert_provider_product_stream(turn.events)
def _scenario_tool_use_then_text_in_history(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
tool_id = "toolu_206_smoke"
payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "We will use echo_smoke once in this session."},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": tool_id,
"name": "echo_smoke",
"input": {"value": "FCC_206_SMOKE"},
},
{
"type": "text",
"text": "Narration after the tool call (issue #206 shape).",
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_id,
"content": "FCC_206_SMOKE",
},
],
},
{
"role": "user",
"content": "Reply in one short sentence: did you see the echo value?",
},
],
"tools": [echo_tool_schema()],
}
with _server_for_provider(smoke_config, provider_model, "tool-206") as server:
turn = ConversationDriver(server, smoke_config).stream(payload)
_assert_provider_product_stream(turn.events)
def _scenario_tool_result_continuation(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
first_payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."}
],
"tools": [echo_tool_schema()],
"tool_choice": {"type": "tool", "name": "echo_smoke"},
"thinking": {"type": "adaptive"},
}
with _server_for_provider(smoke_config, provider_model, "tool") as server:
driver = ConversationDriver(server, smoke_config)
first = driver.stream(first_payload)
tool_uses = _tool_use_blocks_or_skip(
first.events, "provider did not emit a tool_use block"
)
tool_use = tool_uses[0]
second_payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
first_payload["messages"][0],
{"role": "assistant", "content": first.assistant_content},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use["id"],
"content": "FCC_TOOL",
}
],
},
],
"tools": [echo_tool_schema()],
}
second = driver.stream(second_payload)
_assert_provider_product_stream(first.events)
_assert_provider_product_stream(second.events)
def _scenario_gemini_thought_signature_tool_continuation(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
first_payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."}
],
"tools": [echo_tool_schema()],
"tool_choice": {"type": "tool", "name": "echo_smoke"},
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
with _server_for_provider(
smoke_config, provider_model, "gemini-signature"
) as server:
driver = ConversationDriver(server, smoke_config)
first = driver.stream(first_payload)
tool_uses = _tool_use_blocks_or_skip(
first.events, "gemini did not emit a tool_use block"
)
tool_use = tool_uses[0]
signature = _gemini_tool_thought_signature(tool_use)
assert signature, (
"gemini tool_use did not preserve extra_content.google.thought_signature"
)
second_payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
first_payload["messages"][0],
{"role": "assistant", "content": first.assistant_content},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use["id"],
"content": "FCC_TOOL",
}
],
},
],
"tools": [echo_tool_schema()],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
second = driver.stream(second_payload)
_assert_provider_product_stream(first.events)
_assert_provider_product_stream(second.events)
def _gemini_tool_thought_signature(tool_use: dict[str, Any]) -> str | None:
extra_content = tool_use.get("extra_content")
if not isinstance(extra_content, dict):
return None
google = extra_content.get("google")
if not isinstance(google, dict):
return None
signature = google.get("thought_signature")
return signature if isinstance(signature, str) and signature else None
def _scenario_reasoning_tool_continuation(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
payload = {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need to return the echo result."},
{
"type": "tool_use",
"id": "toolu_reasoning_smoke",
"name": "echo_smoke",
"input": {"value": "FCC_TOOL"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_reasoning_smoke",
"content": "FCC_TOOL",
}
],
},
],
"tools": [echo_tool_schema()],
"thinking": {"type": "adaptive"},
}
with _server_for_provider(smoke_config, provider_model, "reasoning-tool") as server:
turn = ConversationDriver(server, smoke_config).stream(payload)
_assert_provider_product_stream(turn.events)
def _scenario_disconnect(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
with _server_for_provider(smoke_config, provider_model, "disconnect") as server:
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
headers=auth_headers(),
json={
"model": "fcc-smoke-default",
"max_tokens": 512,
"messages": [{"role": "user", "content": smoke_config.prompt}],
},
timeout=smoke_config.timeout_s,
) as response:
assert response.status_code == 200, response.read()
for _line in response.iter_lines():
break
health = httpx.get(f"{server.base_url}/health", timeout=5)
assert health.status_code == 200
followup = ConversationDriver(server, smoke_config).ask(
"Reply with one short sentence."
)
_assert_provider_product_stream(followup.events)
def _server_for_provider(
smoke_config: SmokeConfig, provider_model: ProviderModel, name: str
):
return SmokeServerDriver(
smoke_config,
name=f"product-provider-{provider_model.provider}-{name}",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run()
def _openai_auth_headers(smoke_config: SmokeConfig) -> dict[str, str]:
headers = {"content-type": "application/json"}
token = smoke_config.settings.anthropic_auth_token
if token:
headers["authorization"] = f"Bearer {token}"
return headers
@@ -0,0 +1,311 @@
"""Credential-free subprocess coverage for provider generation replacement."""
import json
import threading
import time
from contextlib import ExitStack
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
import httpx
import pytest
from free_claude_code.config.env_template import load_env_template
from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
from smoke.lib.config import SmokeConfig
from smoke.lib.server import RunningServer, start_server
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")]
class FakeOpenAIUpstream:
"""Minimal OpenAI-chat upstream with an optionally held response stream."""
def __init__(self, label: str, *, hold_stream: bool) -> None:
self.label = label
self.hold_stream = hold_stream
self.chat_started = threading.Event()
self.release_stream = threading.Event()
self.chat_requests: list[dict[str, Any]] = []
self._server: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
@property
def base_url(self) -> str:
if self._server is None:
raise RuntimeError("Fake upstream is not running")
port = int(self._server.server_address[1])
return f"http://127.0.0.1:{port}/v1"
def __enter__(self) -> FakeOpenAIUpstream:
upstream = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format: str, *args: Any) -> None:
return
def do_GET(self) -> None:
if self.path.rstrip("/").endswith("/models"):
upstream._send_models(self)
return
self.send_error(404)
def do_POST(self) -> None:
if self.path.rstrip("/").endswith("/chat/completions"):
upstream._send_chat(self)
return
self.send_error(404)
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self._server.daemon_threads = True
self._thread = threading.Thread(
target=self._server.serve_forever,
name=f"fake-openai-{self.label}",
daemon=True,
)
self._thread.start()
return self
def __exit__(self, *_exc: object) -> None:
self.release_stream.set()
if self._server is not None:
self._server.shutdown()
self._server.server_close()
if self._thread is not None:
self._thread.join(timeout=5)
def _send_models(self, handler: BaseHTTPRequestHandler) -> None:
payload = json.dumps(
{
"object": "list",
"data": [
{
"id": f"model-{self.label}",
"object": "model",
"created": 0,
"owned_by": "smoke",
}
],
}
).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(payload)))
handler.end_headers()
handler.wfile.write(payload)
def _send_chat(self, handler: BaseHTTPRequestHandler) -> None:
length = int(handler.headers.get("content-length", "0"))
body = handler.rfile.read(length)
request = json.loads(body) if body else {}
self.chat_requests.append(request)
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("Connection", "close")
handler.end_headers()
try:
self._write_chunk(
handler,
content=f"provider-{self.label}-start ",
finish_reason=None,
)
if self.hold_stream:
time.sleep(0.85)
self._write_chunk(
handler,
content=f"provider-{self.label}-held ",
finish_reason=None,
)
self.chat_started.set()
if self.hold_stream and not self.release_stream.wait(timeout=30):
return
self._write_chunk(
handler,
content=f"provider-{self.label}-finish",
finish_reason=None,
)
self._write_chunk(handler, content=None, finish_reason="stop")
handler.wfile.write(b"data: [DONE]\n\n")
handler.wfile.flush()
except OSError:
return
def _write_chunk(
self,
handler: BaseHTTPRequestHandler,
*,
content: str | None,
finish_reason: str | None,
) -> None:
delta: dict[str, str] = {}
if content is not None:
delta = {"role": "assistant", "content": content}
payload = {
"id": f"chatcmpl-{self.label}",
"object": "chat.completion.chunk",
"created": 0,
"model": f"model-{self.label}",
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason,
}
],
}
handler.wfile.write(f"data: {json.dumps(payload)}\n\n".encode())
handler.wfile.flush()
def _message_payload(*, stream: bool) -> dict[str, Any]:
return {
"model": "claude-sonnet-4-20250514",
"max_tokens": 64,
"messages": [{"role": "user", "content": "generation smoke"}],
"stream": stream,
}
def _write_initial_managed_config(home: Path, upstream: FakeOpenAIUpstream) -> None:
config_path = home / ".fcc" / ".env"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
load_env_template()
+ "\n"
+ "\n".join(
[
"MODEL=lmstudio/model-a",
"MODEL_OPUS=",
"MODEL_SONNET=",
"MODEL_HAIKU=",
f"LM_STUDIO_BASE_URL={upstream.base_url}",
"ANTHROPIC_AUTH_TOKEN=",
"ENABLE_MODEL_THINKING=false",
"MESSAGING_PLATFORM=none",
"",
]
),
encoding="utf-8",
)
def _wait_for_generation_close(server: RunningServer, generation_id: int) -> None:
deadline = time.monotonic() + 10
generation_field = f'"generation_id": {generation_id}'
while time.monotonic() < deadline:
text = server.log_path.read_text(encoding="utf-8", errors="replace")
if "provider_generation.closed" in text and generation_field in text:
return
time.sleep(0.1)
raise AssertionError(
f"generation {generation_id} close trace was not written:\n"
+ server.log_path.read_text(encoding="utf-8", errors="replace")[-3000:]
)
def test_provider_hot_swap_preserves_inflight_stream_e2e(
smoke_config: SmokeConfig,
tmp_path: Path,
) -> None:
home = tmp_path / "home"
downstream_started = threading.Event()
old_body: list[str] = []
old_errors: list[BaseException] = []
credential_env_keys = {
descriptor.credential_env
for descriptor in PROVIDER_CATALOG.values()
if descriptor.credential_env is not None
}
with ExitStack() as stack:
upstream_a = stack.enter_context(FakeOpenAIUpstream("a", hold_stream=True))
upstream_b = stack.enter_context(FakeOpenAIUpstream("b", hold_stream=False))
_write_initial_managed_config(home, upstream_a)
server = stack.enter_context(
start_server(
smoke_config,
name="runtime-ownership",
env_overrides={
"HOME": str(home),
"USERPROFILE": str(home),
},
env_unset=credential_env_keys
| {
"ANTHROPIC_AUTH_TOKEN",
"ENABLE_MODEL_THINKING",
"FCC_ENV_FILE",
"LM_STUDIO_BASE_URL",
"MODEL",
"MODEL_HAIKU",
"MODEL_OPUS",
"MODEL_SONNET",
},
)
)
def consume_old_stream() -> None:
try:
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
json=_message_payload(stream=True),
timeout=30,
) as response:
response.raise_for_status()
downstream_started.set()
old_body.append("".join(response.iter_text()))
except BaseException as exc:
old_errors.append(exc)
downstream_started.set()
old_thread = threading.Thread(
target=consume_old_stream,
name="old-generation-consumer",
)
old_thread.start()
assert downstream_started.wait(timeout=10)
assert upstream_a.chat_started.is_set()
assert old_errors == []
apply_response = httpx.post(
f"{server.base_url}/admin/api/config/apply",
json={
"values": {
"MODEL": "lmstudio/model-b",
"LM_STUDIO_BASE_URL": upstream_b.base_url,
}
},
timeout=smoke_config.timeout_s,
)
assert apply_response.status_code == 200, apply_response.text
apply_body = apply_response.json()
assert apply_body["applied"] is True
assert apply_body["pending_fields"] == []
assert apply_body["restart"]["required"] is False
new_response = httpx.post(
f"{server.base_url}/v1/messages",
json=_message_payload(stream=False),
timeout=smoke_config.timeout_s,
)
assert new_response.status_code == 200, new_response.text
new_text = "".join(
block.get("text", "") for block in new_response.json()["content"]
)
assert new_text == "provider-b-start provider-b-finish"
assert not old_body
upstream_a.release_stream.set()
old_thread.join(timeout=10)
assert not old_thread.is_alive()
assert old_errors == []
assert "provider-a-start " in old_body[0]
assert "provider-a-held " in old_body[0]
assert "provider-a-finish" in old_body[0]
assert len(upstream_a.chat_requests) == 1
assert len(upstream_b.chat_requests) == 1
_wait_for_generation_close(server, generation_id=1)
+68
View File
@@ -0,0 +1,68 @@
import os
from pathlib import Path
import pytest
from free_claude_code.messaging.transcription import TranscriptionService
from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber
from smoke.lib.config import SmokeConfig
from smoke.lib.e2e import VoiceFixtureDriver
pytestmark = [pytest.mark.live]
@pytest.mark.smoke_target("voice")
@pytest.mark.asyncio
async def test_voice_local_backend_e2e(
smoke_config: SmokeConfig, tmp_path: Path
) -> None:
if not smoke_config.settings.voice_note_enabled:
pytest.skip("missing_env: VOICE_NOTE_ENABLED is false")
if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
pytest.skip("missing_env: set FCC_SMOKE_RUN_VOICE=1 to run voice product smoke")
if smoke_config.settings.whisper_device not in {"cpu", "cuda"}:
pytest.skip("missing_env: WHISPER_DEVICE must be cpu or cuda")
wav_path = tmp_path / "voice-local-product.wav"
VoiceFixtureDriver.write_tone_wav(wav_path)
transcriber = TranscriptionService(
model=smoke_config.settings.whisper_model,
device=smoke_config.settings.whisper_device,
huggingface_api_key=smoke_config.settings.huggingface_api_key,
)
try:
text = await transcriber.transcribe(wav_path)
except ImportError as exc:
pytest.skip(f"missing_env: {exc}")
finally:
await transcriber.close()
assert isinstance(text, str)
assert text.strip()
@pytest.mark.smoke_target("voice")
@pytest.mark.asyncio
async def test_voice_nim_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
if not smoke_config.settings.voice_note_enabled:
pytest.skip("missing_env: VOICE_NOTE_ENABLED is false")
if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
pytest.skip("missing_env: set FCC_SMOKE_RUN_VOICE=1 to run voice product smoke")
if smoke_config.settings.whisper_device != "nvidia_nim":
pytest.skip("missing_env: WHISPER_DEVICE must be nvidia_nim")
if not smoke_config.settings.nvidia_nim_api_key.strip():
pytest.skip("missing_env: NVIDIA_NIM_API_KEY is required")
wav_path = tmp_path / "voice-nim-product.wav"
VoiceFixtureDriver.write_tone_wav(wav_path)
transcriber = NvidiaNimTranscriber(
model=smoke_config.settings.whisper_model,
api_key=smoke_config.settings.nvidia_nim_api_key,
)
try:
text = await transcriber.transcribe(wav_path)
finally:
await transcriber.close()
assert isinstance(text, str)
assert text.strip()
+1
View File
@@ -0,0 +1 @@
"""Free Claude Code package."""
+1
View File
@@ -0,0 +1 @@
"""HTTP API adapter for Free Claude Code."""
+200
View File
@@ -0,0 +1,200 @@
"""Local admin UI routes and APIs."""
import ipaddress
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from free_claude_code.config.admin.manifest import FIELD_BY_KEY
from free_claude_code.config.admin.persistence import validate_updates
from free_claude_code.config.admin.values import load_config_response
from .dependencies import get_services
from .ports import ApiServices
router = APIRouter()
STATIC_DIR = Path(__file__).resolve().parent / "admin_static"
LOCAL_PROVIDER_PATHS = {
"lmstudio": "/models",
"llamacpp": "/models",
"ollama": "/api/tags",
}
class AdminConfigPayload(BaseModel):
"""Partial config update submitted by the admin UI."""
values: dict[str, Any] = Field(default_factory=dict)
def _is_loopback_host(host: str | None) -> bool:
if host is None:
return False
normalized = host.strip().strip("[]").lower()
if normalized == "localhost":
return True
try:
return ipaddress.ip_address(normalized).is_loopback
except ValueError:
return False
def _origin_is_local(origin: str | None) -> bool:
if not origin:
return True
parsed = urlsplit(origin)
return _is_loopback_host(parsed.hostname)
def require_loopback_admin(request: Request) -> None:
"""Allow admin access only from the local machine."""
client_host = request.client.host if request.client else None
if not _is_loopback_host(client_host):
raise HTTPException(status_code=403, detail="Admin UI is local-only")
origin = request.headers.get("origin")
if not _origin_is_local(origin):
raise HTTPException(status_code=403, detail="Admin UI is local-only")
def _asset_response(filename: str) -> FileResponse:
path = STATIC_DIR / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="Admin asset not found")
return FileResponse(path)
@router.get("/admin", include_in_schema=False)
async def admin_page(request: Request):
require_loopback_admin(request)
return _asset_response("index.html")
@router.get("/admin/assets/{filename}", include_in_schema=False)
async def admin_asset(filename: str, request: Request):
require_loopback_admin(request)
if filename not in {"admin.css", "admin.js"}:
raise HTTPException(status_code=404, detail="Admin asset not found")
return _asset_response(filename)
@router.get("/admin/api/config")
async def get_admin_config(request: Request):
require_loopback_admin(request)
return load_config_response()
@router.post("/admin/api/config/validate")
async def validate_admin_config(payload: AdminConfigPayload, request: Request):
require_loopback_admin(request)
return validate_updates(_filtered_values(payload.values))
@router.post("/admin/api/config/apply")
async def apply_admin_config(
payload: AdminConfigPayload,
request: Request,
background_tasks: BackgroundTasks,
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
result = await services.admin.apply_admin_config(_filtered_values(payload.values))
restart = result.get("restart")
if isinstance(restart, dict) and restart.get("automatic"):
background_tasks.add_task(services.admin.request_restart)
return result
@router.get("/admin/api/status")
async def admin_status(
request: Request,
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
return services.admin.admin_status()
@router.get("/admin/api/providers/local-status")
async def local_provider_status(request: Request):
require_loopback_admin(request)
config = load_config_response()
values = {field["key"]: field["value"] for field in config["fields"]}
checks = []
for provider_id, path in LOCAL_PROVIDER_PATHS.items():
base_url = _local_provider_url(provider_id, values)
checks.append(await _check_local_provider(provider_id, base_url, path))
return {"providers": checks}
@router.post("/admin/api/providers/{provider_id}/test")
async def test_provider(
provider_id: str,
request: Request,
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
return await services.admin.test_provider(provider_id)
@router.post("/admin/api/models/refresh")
async def refresh_models(
request: Request,
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
return await services.admin.refresh_models()
def _filtered_values(values: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in values.items() if key in FIELD_BY_KEY}
def _local_provider_url(provider_id: str, values: dict[str, str]) -> str:
if provider_id == "lmstudio":
return values.get("LM_STUDIO_BASE_URL", "")
if provider_id == "llamacpp":
return values.get("LLAMACPP_BASE_URL", "")
if provider_id == "ollama":
return values.get("OLLAMA_BASE_URL", "")
return ""
async def _check_local_provider(
provider_id: str, base_url: str, path: str
) -> dict[str, Any]:
clean_url = base_url.strip().rstrip("/")
if not clean_url:
return {
"provider_id": provider_id,
"status": "missing_url",
"label": "Missing URL",
"base_url": base_url,
}
url = f"{clean_url}{path}"
try:
async with httpx.AsyncClient(timeout=1.5) as client:
response = await client.get(url)
ok = 200 <= response.status_code < 300
return {
"provider_id": provider_id,
"status": "reachable" if ok else "offline",
"label": "Reachable" if ok else "Offline",
"base_url": base_url,
"status_code": response.status_code,
}
except Exception as exc:
return {
"provider_id": provider_id,
"status": "offline",
"label": "Offline",
"base_url": base_url,
"error_type": type(exc).__name__,
}
@@ -0,0 +1,736 @@
:root {
color-scheme: dark;
/* Color Palette */
--bg: #090a0f;
--panel: #11131c;
--panel-strong: #171b26;
--card: #151822;
--card-hover: #1e2230;
--input: #0a0b10;
--input-focus: #0f1118;
--text: #f3f4f6;
--text-strong: #ffffff;
--muted: #9ca3af;
--line: rgba(255, 255, 255, 0.06);
--line-strong: rgba(255, 255, 255, 0.12);
/* Brand and Accent Colors */
--accent: #10b981;
--accent-dark: #059669;
--accent-muted: rgba(16, 185, 129, 0.08);
--accent-border: rgba(16, 185, 129, 0.3);
/* Status Colors */
--ok: #10b981;
--ok-bg: rgba(16, 185, 129, 0.08);
--ok-border: rgba(16, 185, 129, 0.25);
--warn: #f59e0b;
--warn-bg: rgba(245, 158, 11, 0.08);
--warn-border: rgba(245, 158, 11, 0.25);
--error: #ef4444;
--error-bg: rgba(239, 68, 68, 0.08);
--error-border: rgba(239, 68, 68, 0.25);
--neutral: #6b7280;
--neutral-bg: rgba(107, 114, 128, 0.08);
--neutral-border: rgba(107, 114, 128, 0.25);
--info: #3b82f6;
/* Box Shadow & Glows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow: 0 12px 34px rgba(0, 0, 0, 0.5);
--glow-accent: 0 0 12px rgba(16, 185, 129, 0.15);
/* Borders and Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
/* Typography */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--panel-strong);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
/* App Shell Layout */
.app-shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 100vh;
padding-bottom: 96px; /* Space for action bar */
}
/* Sidebar Navigation */
.sidebar {
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid var(--line);
background: var(--bg);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 32px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: #ffffff;
font-weight: 800;
font-size: 15px;
box-shadow: var(--glow-accent);
}
.brand h1 {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
color: var(--text-strong);
margin: 0;
}
.brand p {
color: var(--muted);
font-size: 12px;
margin: 0;
}
.section-nav {
display: grid;
gap: 6px;
}
.nav-link {
display: flex;
align-items: center;
width: 100%;
min-height: 40px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
padding: 10px 14px;
text-align: left;
cursor: pointer;
font-weight: 600;
font-size: 14px;
transition: all var(--transition-fast);
}
.nav-link:hover {
background: var(--panel-strong);
color: var(--text-strong);
}
.nav-link.active {
background: var(--accent-muted);
border-color: var(--accent-border);
color: var(--accent);
box-shadow: var(--shadow-sm);
}
/* Accessible focus outlines */
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.nav-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Main Content Area */
.main {
min-width: 0;
padding: 40px 48px;
}
.topbar {
margin-bottom: 32px;
}
.topbar h2 {
font-size: 30px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text-strong);
margin: 0;
}
/* Provider Strip & Panels */
.provider-strip,
.settings-section {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
margin-bottom: 24px;
transition: border-color var(--transition-normal);
}
.provider-strip {
padding: 24px;
}
.strip-header,
.section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.strip-header h3,
.section-heading h3 {
font-size: 18px;
font-weight: 700;
color: var(--text-strong);
margin: 0;
}
.section-heading p {
color: var(--muted);
font-size: 13px;
line-height: 1.4;
margin: 4px 0 0 0;
}
/* Provider Status Cards */
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.provider-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
min-height: 140px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 16px;
background: var(--card);
transition: all var(--transition-normal);
}
.provider-card:hover {
border-color: var(--line-strong);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
background: var(--card-hover);
}
.provider-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.provider-title strong {
font-size: 14px;
font-weight: 700;
color: var(--text-strong);
}
/* Modern status pills with color indicators */
.status-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
border: 1px solid var(--neutral-border);
border-radius: var(--radius-full);
padding: 2px 10px;
background: var(--neutral-bg);
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.status-pill.ok {
color: var(--ok);
background: var(--ok-bg);
border-color: var(--ok-border);
}
.status-pill.warn {
color: var(--warn);
background: var(--warn-bg);
border-color: var(--warn-border);
}
.status-pill.error {
color: var(--error);
background: var(--error-bg);
border-color: var(--error-border);
}
.provider-meta {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Button & Form Controls styling */
.test-button,
.ghost-button,
.secondary-button,
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
border-radius: var(--radius-md);
border: 1px solid var(--line-strong);
padding: 6px 14px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
}
.test-button {
background: var(--panel-strong);
color: var(--text);
border-color: var(--line);
margin-top: auto;
align-self: flex-start;
}
.test-button:hover:not(:disabled) {
background: var(--card-hover);
border-color: var(--accent);
color: var(--accent);
}
.test-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.secondary-button {
background: transparent;
color: var(--text);
border-color: var(--line-strong);
}
.secondary-button:hover:not(:disabled) {
background: var(--panel-strong);
border-color: var(--text);
}
.primary-button {
border-color: var(--accent);
background: var(--accent);
color: #06100b;
}
.primary-button:hover:not(:disabled) {
background: var(--accent-dark);
}
.primary-button:disabled {
cursor: not-allowed;
border-color: var(--line);
background: var(--panel-strong);
color: var(--muted);
}
.ghost-button {
background: transparent;
color: var(--muted);
border-color: transparent;
}
.ghost-button:hover:not(:disabled) {
color: var(--text-strong);
background: var(--panel-strong);
}
/* Forms layout */
.form-sections {
display: grid;
gap: 24px;
}
.settings-section {
padding: 24px;
scroll-margin-top: 24px;
}
.section-heading {
border-bottom: 1px solid var(--line);
padding-bottom: 16px;
margin-bottom: 24px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
/* Field Grouping */
.field {
display: flex;
flex-direction: column;
gap: 8px;
align-content: start;
}
.field label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--text-strong);
}
.field-source {
color: var(--accent);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--accent-muted);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
/* Form Inputs, Select, Textareas */
.field input[type="text"],
.field input[type="number"],
.field input[type="password"],
.field select,
.field textarea {
width: 100%;
min-height: 40px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--input);
color: var(--text-strong);
padding: 10px 14px;
font-size: 14px;
transition: all var(--transition-fast);
}
/* Custom Dropdown Styling for Select Elements */
.field select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 14px;
padding-right: 36px;
}
/* Custom Dropdown Styling for Datalist Input Elements (Model Routing) */
.field input[list] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 14px;
padding-right: 36px;
}
/* Hide WebKit's native picker indicator to avoid double arrow icons */
.field input[list]::-webkit-calendar-picker-indicator {
display: none !important;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--accent);
background: var(--input-focus);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
outline: none;
}
/* Checkbox specific layout styling */
.field input[type="checkbox"] {
align-self: flex-start;
width: 20px;
height: 20px;
accent-color: var(--accent);
cursor: pointer;
margin: 4px 0;
}
.field textarea {
min-height: 100px;
resize: vertical;
line-height: 1.5;
}
.field input:disabled,
.field select:disabled,
.field textarea:disabled {
background: rgba(255, 255, 255, 0.02);
border-color: var(--line);
color: var(--muted);
cursor: not-allowed;
}
.field-description {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
margin-top: 2px;
}
/* Show/Hide Advanced fields */
.field.advanced-field {
display: none;
}
.settings-section.show-advanced .advanced-field {
display: flex;
}
.advanced-toggle {
margin-top: 20px;
font-size: 12px;
border: 1px solid var(--line-strong);
}
/* Fixed Bottom Action Bar */
.action-bar {
position: fixed;
right: 0;
bottom: 0;
left: 260px;
z-index: 100;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(200px, 1fr) auto;
gap: 20px;
align-items: center;
min-height: 80px;
border-top: 1px solid var(--line);
background: rgba(9, 10, 15, 0.82);
padding: 16px 40px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.3);
}
.action-meta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.action-meta strong {
font-size: 14px;
font-weight: 700;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-meta span {
color: var(--muted);
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-area {
min-width: 0;
color: var(--muted);
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.message-area.error {
color: var(--error);
font-weight: 600;
}
.message-area.ok {
color: var(--ok);
font-weight: 600;
}
.action-buttons {
display: flex;
gap: 12px;
}
.action-buttons button {
min-width: 100px;
}
/* Tablet Layout Optimization */
@media (max-width: 900px) {
.app-shell {
display: flex;
flex-direction: column;
padding-bottom: 180px; /* More room for stacked action bar */
}
.sidebar {
position: relative;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
padding: 20px;
gap: 20px;
}
.section-nav {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
.main {
padding: 24px 20px;
}
.action-bar {
left: 0;
grid-template-columns: 1fr;
gap: 12px;
padding: 16px 20px;
min-height: auto;
background: rgba(9, 10, 15, 0.95);
}
.action-meta {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.action-meta span {
text-align: right;
}
.action-buttons {
width: 100%;
}
.action-buttons button {
flex: 1;
min-height: 44px; /* Better touch target on mobile */
}
}
/* Mobile Layout Optimization */
@media (max-width: 600px) {
.brand {
justify-content: center;
text-align: center;
flex-direction: column;
}
.brand-mark {
width: 48px;
height: 48px;
}
.section-nav {
grid-template-columns: 1fr;
}
.topbar {
text-align: center;
}
.topbar h2 {
font-size: 24px;
}
.provider-grid {
grid-template-columns: 1fr;
}
.field-grid {
grid-template-columns: 1fr;
}
.action-meta {
flex-direction: column;
align-items: stretch;
gap: 2px;
}
.action-meta span {
text-align: left;
}
}
@@ -0,0 +1,475 @@
const state = {
config: null,
fields: new Map(),
localStatus: new Map(),
modelOptions: [],
activeView: "providers",
};
const MASKED_SECRET = "********";
const VIEW_GROUPS = [
{
id: "providers",
label: "Providers",
title: "Providers",
sections: ["providers", "runtime"],
containerId: "providersSections",
},
{
id: "model_config",
label: "Model Config",
title: "Model Config",
sections: ["models", "thinking", "web_tools"],
containerId: "modelConfigSections",
},
{
id: "messaging",
label: "Messaging",
title: "Messaging",
sections: ["messaging", "voice"],
containerId: "messagingSections",
},
];
const byId = (id) => document.getElementById(id);
function sourceLabel(source) {
const labels = {
default: "default",
template: "template",
repo_env: "repo .env",
managed_env: "",
explicit_env_file: "FCC_ENV_FILE",
process: "process env",
};
return Object.prototype.hasOwnProperty.call(labels, source) ? labels[source] : source;
}
function sourceText(field) {
const parts = [];
const label = sourceLabel(field.source);
if (label) {
parts.push(label);
}
if (field.locked) {
parts.push("locked");
}
return parts.join(" ");
}
function statusClass(status) {
if (["configured", "reachable", "running"].includes(status)) return "ok";
if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn";
if (["offline", "error"].includes(status)) return "error";
return "neutral";
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
async function load() {
showMessage("Loading admin config");
const config = await api("/admin/api/config");
state.config = config;
state.fields = new Map(config.fields.map((field) => [field.key, field]));
renderNav();
renderProviders(config.provider_status);
renderSections(config.sections, config.fields);
byId("configPath").textContent = config.paths.managed;
await validate(false);
await refreshLocalStatus();
updateDirtyState();
showMessage("");
}
function renderNav() {
const nav = byId("sectionNav");
nav.innerHTML = "";
VIEW_GROUPS.forEach((view, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = `nav-link${index === 0 ? " active" : ""}`;
button.dataset.view = view.id;
button.textContent = view.label;
if (index === 0) {
button.setAttribute("aria-current", "page");
}
button.addEventListener("click", () => {
setActiveView(view.id, { scroll: true });
});
nav.appendChild(button);
});
setActiveView(state.activeView, { scroll: false });
}
function setActiveView(viewId, { scroll = false } = {}) {
const activeView =
VIEW_GROUPS.find((view) => view.id === viewId) || VIEW_GROUPS[0];
state.activeView = activeView.id;
byId("pageTitle").textContent = activeView.title;
document.querySelectorAll(".nav-link").forEach((link) => {
const selected = link.dataset.view === activeView.id;
link.classList.toggle("active", selected);
if (selected) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
document.querySelectorAll(".admin-view").forEach((view) => {
const selected = view.dataset.view === activeView.id;
view.classList.toggle("active", selected);
view.hidden = !selected;
});
if (scroll) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
function renderProviders(providerStatus) {
const grid = byId("providerGrid");
grid.innerHTML = "";
providerStatus.forEach((provider) => {
const card = document.createElement("article");
card.className = "provider-card";
card.dataset.provider = provider.provider_id;
const title = document.createElement("div");
title.className = "provider-title";
title.innerHTML = `<strong>${provider.display_name || provider.provider_id}</strong>`;
const pill = document.createElement("span");
pill.className = `status-pill ${statusClass(provider.status)}`;
pill.textContent = provider.label;
title.appendChild(pill);
const meta = document.createElement("div");
meta.className = "provider-meta";
meta.textContent =
provider.kind === "local"
? provider.base_url || "No local URL configured"
: provider.credential_env;
const button = document.createElement("button");
button.type = "button";
button.className = "test-button";
button.textContent = provider.kind === "local" ? "Test" : "Refresh models";
button.addEventListener("click", () => testProvider(provider.provider_id, button));
card.append(title, meta, button);
grid.appendChild(card);
});
}
function updateProviderCard(providerId, status, label, metaText) {
const card = document.querySelector(`[data-provider="${providerId}"]`);
if (!card) return;
const pill = card.querySelector(".status-pill");
pill.className = `status-pill ${statusClass(status)}`;
pill.textContent = label;
if (metaText) {
card.querySelector(".provider-meta").textContent = metaText;
}
}
function renderSections(sections, fields) {
VIEW_GROUPS.forEach((view) => {
byId(view.containerId).innerHTML = "";
});
const sectionById = new Map(sections.map((section) => [section.id, section]));
const bySection = new Map();
sections.forEach((section) => bySection.set(section.id, []));
fields.forEach((field) => {
if (!bySection.has(field.section)) bySection.set(field.section, []);
bySection.get(field.section).push(field);
});
VIEW_GROUPS.forEach((view) => {
const container = byId(view.containerId);
view.sections.forEach((sectionId) => {
const section = sectionById.get(sectionId);
const sectionFields = bySection.get(sectionId) || [];
if (!section || sectionFields.length === 0) return;
const sectionEl = document.createElement("section");
sectionEl.className = "settings-section";
sectionEl.id = `section-${section.id}`;
const heading = document.createElement("div");
heading.className = "section-heading";
heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
sectionEl.appendChild(heading);
const grid = document.createElement("div");
grid.className = "field-grid";
sectionFields.forEach((field) => {
grid.appendChild(renderField(field));
});
sectionEl.appendChild(grid);
if (sectionFields.some((field) => field.advanced)) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ghost-button advanced-toggle";
toggle.textContent = "Show advanced";
toggle.addEventListener("click", () => {
const showing = sectionEl.classList.toggle("show-advanced");
toggle.textContent = showing ? "Hide advanced" : "Show advanced";
});
sectionEl.appendChild(toggle);
}
container.appendChild(sectionEl);
});
});
}
function renderField(field) {
const wrapper = document.createElement("div");
wrapper.className = `field${field.advanced ? " advanced-field" : ""}`;
wrapper.dataset.key = field.key;
const label = document.createElement("label");
label.htmlFor = `field-${field.key}`;
const labelText = document.createElement("span");
labelText.textContent = field.label;
label.appendChild(labelText);
const source = sourceText(field);
if (source) {
const sourceEl = document.createElement("span");
sourceEl.className = "field-source";
sourceEl.textContent = source;
label.appendChild(sourceEl);
}
const input = inputForField(field);
input.id = `field-${field.key}`;
input.dataset.key = field.key;
input.dataset.original = field.value || "";
input.dataset.secret = field.secret ? "true" : "false";
input.dataset.configured = field.configured ? "true" : "false";
input.disabled = field.locked;
input.addEventListener("input", updateDirtyState);
input.addEventListener("change", updateDirtyState);
wrapper.append(label, input);
if (field.description) {
const description = document.createElement("div");
description.className = "field-description";
description.textContent = field.description;
wrapper.appendChild(description);
}
return wrapper;
}
function inputForField(field) {
if (field.type === "boolean") {
const input = document.createElement("input");
input.type = "checkbox";
input.checked = String(field.value).toLowerCase() === "true";
input.dataset.original = input.checked ? "true" : "false";
return input;
}
if (field.type === "tri_boolean") {
const select = document.createElement("select");
[
["", "Inherit"],
["true", "Enabled"],
["false", "Disabled"],
].forEach(([value, label]) => select.appendChild(option(value, label)));
select.value = field.value || "";
return select;
}
if (field.type === "select") {
const select = document.createElement("select");
field.options.forEach((value) => select.appendChild(option(value, value)));
select.value = field.value || field.options[0] || "";
return select;
}
if (field.type === "textarea") {
const textarea = document.createElement("textarea");
textarea.value = field.value || "";
return textarea;
}
const input = document.createElement("input");
input.type = field.type === "number" ? "number" : "text";
if (field.type === "secret") {
input.type = "password";
input.placeholder = field.configured
? "Configured - enter a new value to replace"
: "Not configured";
input.value = "";
input.autocomplete = "off";
} else {
input.value = field.value || "";
}
if (field.key.startsWith("MODEL")) {
input.setAttribute("list", "model-options");
}
return input;
}
function option(value, label) {
const optionEl = document.createElement("option");
optionEl.value = value;
optionEl.textContent = label;
return optionEl;
}
function readFieldValue(input) {
if (input.type === "checkbox") return input.checked ? "true" : "false";
if (input.dataset.secret === "true" && input.dataset.configured === "true") {
return input.value ? input.value : MASKED_SECRET;
}
return input.value;
}
function changedValues() {
const values = {};
document.querySelectorAll("[data-key]").forEach((input) => {
if (input.disabled || !input.matches("input, select, textarea")) return;
const value = readFieldValue(input);
if (value !== input.dataset.original) {
values[input.dataset.key] = value;
}
});
return values;
}
function updateDirtyState() {
const count = Object.keys(changedValues()).length;
byId("dirtyState").textContent =
count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`;
byId("applyButton").disabled = count === 0;
}
async function validate(showResult = true) {
const result = await api("/admin/api/config/validate", {
method: "POST",
body: JSON.stringify({ values: changedValues() }),
});
if (showResult) {
showValidationResult(result);
}
return result;
}
function showValidationResult(result) {
if (result.valid) {
showMessage("Config shape is valid", "ok");
} else {
showMessage(result.errors.join("; "), "error");
}
}
async function apply() {
const result = await api("/admin/api/config/apply", {
method: "POST",
body: JSON.stringify({ values: changedValues() }),
});
if (!result.applied) {
showValidationResult(result);
return;
}
const restart = result.restart || {};
if (restart.required && restart.automatic) {
showMessage("Applied. Restarting server...", "ok");
byId("applyButton").disabled = true;
setTimeout(() => {
window.location.href = restart.admin_url || "/admin";
}, 1600);
return;
}
const pending = restart.required ? restart.fields || [] : result.pending_fields || [];
await load();
showMessage(
pending.length
? `Applied. Restart fcc-server to use: ${pending.join(", ")}`
: "Applied",
"ok",
);
}
async function refreshLocalStatus() {
const result = await api("/admin/api/providers/local-status");
result.providers.forEach((provider) => {
state.localStatus.set(provider.provider_id, provider);
const meta = provider.status_code
? `${provider.base_url} returned HTTP ${provider.status_code}`
: provider.base_url;
updateProviderCard(provider.provider_id, provider.status, provider.label, meta);
});
}
async function testProvider(providerId, button) {
const original = button.textContent;
button.disabled = true;
button.textContent = "Testing";
try {
const result = await api(`/admin/api/providers/${providerId}/test`, {
method: "POST",
body: "{}",
});
if (result.ok) {
updateProviderCard(
providerId,
"reachable",
`${result.models.length} models`,
result.models.slice(0, 3).join(", ") || "No models returned",
);
state.modelOptions = Array.from(
new Set([
...state.modelOptions,
...result.models.map((model) => `${providerId}/${model}`),
]),
).sort();
syncModelDatalist();
} else {
updateProviderCard(providerId, "offline", result.error_type, result.error_type);
}
} finally {
button.disabled = false;
button.textContent = original;
}
}
function syncModelDatalist() {
let datalist = byId("model-options");
if (!datalist) {
datalist = document.createElement("datalist");
datalist.id = "model-options";
document.body.appendChild(datalist);
}
datalist.innerHTML = "";
state.modelOptions.forEach((model) => datalist.appendChild(option(model, model)));
}
function showMessage(message, kind = "") {
const area = byId("messageArea");
area.textContent = message;
area.className = `message-area ${kind}`.trim();
}
byId("validateButton").addEventListener("click", () => validate(true));
byId("applyButton").addEventListener("click", apply);
load().catch((error) => {
showMessage(error.message, "error");
});
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Free Claude Code Admin</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/admin/assets/admin.css" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">FC</div>
<div>
<h1>Free Claude Code</h1>
<p>Server Control</p>
</div>
</div>
<nav id="sectionNav" class="section-nav" aria-label="Admin views"></nav>
</aside>
<main class="main">
<header class="topbar">
<div>
<h2 id="pageTitle">Providers</h2>
</div>
</header>
<div id="adminViews" class="admin-views">
<section id="view-providers" class="admin-view active" data-view="providers">
<section class="provider-strip" aria-label="Provider status">
<div class="strip-header">
<h3>Providers</h3>
</div>
<div id="providerGrid" class="provider-grid"></div>
</section>
<div
id="providersSections"
class="form-sections"
aria-label="Provider configuration"
></div>
</section>
<section id="view-model_config" class="admin-view" data-view="model_config" hidden>
<div
id="modelConfigSections"
class="form-sections"
aria-label="Model configuration"
></div>
</section>
<section id="view-messaging" class="admin-view" data-view="messaging" hidden>
<div
id="messagingSections"
class="form-sections"
aria-label="Messaging configuration"
></div>
</section>
</div>
</main>
<footer class="action-bar">
<div class="action-meta">
<strong id="dirtyState">No changes</strong>
<span id="configPath"></span>
</div>
<div id="messageArea" class="message-area"></div>
<div class="action-buttons">
<button id="validateButton" class="secondary-button" type="button">Validate</button>
<button id="applyButton" class="primary-button" type="button" disabled>Apply</button>
</div>
</footer>
</div>
<script src="/admin/assets/admin.js"></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
"""Pure FastAPI application factory."""
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from free_claude_code.application.errors import ApplicationError
from free_claude_code.core.anthropic import anthropic_error_payload
from free_claude_code.core.diagnostics import (
redacted_exception_traceback,
safe_exception_message,
)
from free_claude_code.core.openai_responses import openai_error_payload
from free_claude_code.core.trace import (
extract_claude_session_id_from_headers,
trace_event,
)
from free_claude_code.core.version import package_version
from .admin_routes import router as admin_router
from .ports import ApiServices
from .request_errors import ordinary_application_error_response
from .request_ids import (
RequestCorrelationMiddleware,
attach_request_id_headers,
get_request_id,
)
from .routes import router
from .validation_log import summarize_request_validation_body
def create_app(services: ApiServices) -> FastAPI:
"""Create the HTTP adapter around explicitly supplied runtime services."""
app = FastAPI(title="Claude Code Proxy", version=package_version())
app.state.services = services
app.add_middleware(RequestCorrelationMiddleware)
app.include_router(admin_router)
app.include_router(router)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
"""Log request shape for 422 debugging without content values."""
body: Any
try:
body = await request.json()
except Exception as error:
body = {"_json_error": type(error).__name__}
message_summary, tool_names = summarize_request_validation_body(body)
trace_event(
stage="ingress",
event="server.request.validation_failed",
source="api",
path=request.url.path,
query=dict(request.query_params),
error_locs=[list(error.get("loc", ())) for error in exc.errors()],
error_types=[str(error.get("type", "")) for error in exc.errors()],
message_summary=message_summary,
tool_names=tool_names,
)
return await request_validation_exception_handler(request, exc)
@app.exception_handler(ApplicationError)
async def application_error_handler(request: Request, exc: ApplicationError):
"""Serialize defensive application failures in the selected wire protocol."""
return ordinary_application_error_response(
exc,
wire_api=(
"responses" if request.url.path == "/v1/responses" else "messages"
),
request_id=get_request_id(request),
)
@app.exception_handler(Exception)
async def general_error_handler(request: Request, exc: Exception):
"""Handle general errors and return Anthropic format."""
request_id = get_request_id(request)
claude_sid = extract_claude_session_id_from_headers(request.headers)
settings = services.requests.current_settings()
with logger.contextualize(
http_method=request.method,
http_path=request.url.path,
claude_session_id=claude_sid,
request_id=request_id,
):
if settings.log_api_error_tracebacks:
logger.error("General Error: {}", safe_exception_message(exc))
logger.error(redacted_exception_traceback(exc))
else:
logger.error(
"General Error: path={} method={} exc_type={}",
request.url.path,
request.method,
type(exc).__name__,
)
message = safe_exception_message(exc)
if request.url.path == "/v1/responses":
content = openai_error_payload(message=message, error_type="api_error")
else:
content = anthropic_error_payload(
error_type="api_error",
message=message,
request_id=request_id,
)
response = JSONResponse(status_code=500, content=content)
attach_request_id_headers(
response,
request_id=request_id,
path=request.url.path,
)
return response
return app
+164
View File
@@ -0,0 +1,164 @@
"""Command parsing utilities for API optimizations."""
import re
import shlex
_ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
def _is_env_assignment(part: str) -> bool:
"""Return True when a token is a shell-style env assignment."""
return bool(_ENV_ASSIGNMENT_RE.match(part))
def _strip_env_assignments(parts: list[str]) -> list[str]:
"""Return command parts after leading shell-style env assignments."""
cmd_start = 0
for i, part in enumerate(parts):
if _is_env_assignment(part):
cmd_start = i + 1
else:
break
return parts[cmd_start:]
def extract_command_prefix(command: str) -> str:
"""Extract the command prefix for fast prefix detection.
Parses a shell command safely, handling environment variables and
command injection attempts. Returns the command prefix suitable
for quick identification.
Returns:
Command prefix (e.g., "git", "git commit", "npm install")
or "none" if no valid command found
"""
if "`" in command or "$(" in command:
return "command_injection_detected"
try:
parts = shlex.split(command, posix=False)
if not parts:
return "none"
env_prefix = []
cmd_start = 0
for i, part in enumerate(parts):
if _is_env_assignment(part):
env_prefix.append(part)
cmd_start = i + 1
else:
break
if cmd_start >= len(parts):
return "none"
cmd_parts = parts[cmd_start:]
if not cmd_parts:
return "none"
first_word = cmd_parts[0]
two_word_commands = {
"git",
"npm",
"docker",
"kubectl",
"cargo",
"go",
"pip",
"yarn",
}
if first_word in two_word_commands and len(cmd_parts) > 1:
second_word = cmd_parts[1]
if not second_word.startswith("-"):
return f"{first_word} {second_word}"
return first_word
return first_word if not env_prefix else " ".join(env_prefix) + " " + first_word
except ValueError:
parts = command.split()
if not parts:
return "none"
cmd_parts = _strip_env_assignments(parts)
return cmd_parts[0] if cmd_parts else "none"
def extract_filepaths_from_command(command: str, output: str) -> str:
"""Extract file paths from a command locally without API call.
Determines if the command reads file contents and extracts paths accordingly.
Commands like ls/dir/find just list files, so return empty.
Commands like cat/head/tail actually read contents, so extract the file path.
Returns:
Filepath extraction result in <filepaths> format
"""
listing_commands = {
"ls",
"dir",
"find",
"tree",
"pwd",
"cd",
"mkdir",
"rmdir",
"rm",
}
reading_commands = {"cat", "head", "tail", "less", "more", "bat", "type"}
try:
parts = shlex.split(command, posix=False)
if not parts:
return "<filepaths>\n</filepaths>"
cmd_parts = _strip_env_assignments(parts)
if not cmd_parts:
return "<filepaths>\n</filepaths>"
base_cmd = cmd_parts[0].split("/")[-1].split("\\")[-1].lower()
if base_cmd in listing_commands:
return "<filepaths>\n</filepaths>"
if base_cmd in reading_commands:
filepaths = []
for part in cmd_parts[1:]:
if part.startswith("-"):
continue
filepaths.append(part)
if filepaths:
paths_str = "\n".join(filepaths)
return f"<filepaths>\n{paths_str}\n</filepaths>"
return "<filepaths>\n</filepaths>"
if base_cmd == "grep":
flags_with_args = {"-e", "-f", "-m", "-A", "-B", "-C"}
pattern_provided_via_flag = False
positional = []
skip_next = False
for part in cmd_parts[1:]:
if skip_next:
skip_next = False
continue
if part.startswith("-"):
if part in flags_with_args:
if part in {"-e", "-f"}:
pattern_provided_via_flag = True
skip_next = True
continue
positional.append(part)
filepaths = positional if pattern_provided_via_flag else positional[1:]
if filepaths:
paths_str = "\n".join(filepaths)
return f"<filepaths>\n{paths_str}\n</filepaths>"
return "<filepaths>\n</filepaths>"
return "<filepaths>\n</filepaths>"
except ValueError:
return "<filepaths>\n</filepaths>"
+74
View File
@@ -0,0 +1,74 @@
"""FastAPI dependencies for the explicit runtime service boundary."""
import secrets
from fastapi import Depends, HTTPException, Request
from loguru import logger
from free_claude_code.application.errors import UnknownProviderError
from free_claude_code.application.ports import ProviderPort, RequestRuntimeLease
from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
from free_claude_code.config.settings import Settings
from .ports import ApiServices
def get_services(request: Request) -> ApiServices:
"""Return the complete services supplied when the app was constructed."""
return request.app.state.services
def get_settings(services: ApiServices = Depends(get_services)) -> Settings:
"""Return the current request-runtime settings snapshot."""
return services.requests.current_settings()
def resolve_provider(
provider_type: str,
*,
lease: RequestRuntimeLease,
) -> ProviderPort:
"""Resolve a provider through one retained generation."""
should_log_init = not lease.is_provider_cached(provider_type)
try:
provider = lease.resolve_provider(provider_type)
except UnknownProviderError:
logger.error(
"Unknown provider_type: '{}'. Supported: {}",
provider_type,
", ".join(f"'{key}'" for key in PROVIDER_CATALOG),
)
raise
if should_log_init:
logger.info("Provider initialized: {}", provider_type)
return provider
def require_api_key(
request: Request,
settings: Settings = Depends(get_settings),
) -> None:
"""Require the configured Anthropic-style server API key."""
anthropic_auth_token = settings.anthropic_auth_token.strip()
if not anthropic_auth_token:
return
header = (
request.headers.get("x-api-key")
or request.headers.get("authorization")
or request.headers.get("anthropic-auth-token")
)
if not header:
raise HTTPException(status_code=401, detail="Missing API key")
token = header.strip()
if header.lower().startswith("bearer "):
token = header.split(" ", 1)[1].strip()
if token and ":" in token:
token = token.split(":", 1)[0].strip()
if not secrets.compare_digest(
token.encode("utf-8"),
anthropic_auth_token.encode("utf-8"),
):
raise HTTPException(status_code=401, detail="Invalid API key")
+150
View File
@@ -0,0 +1,150 @@
"""Request detection utilities for API optimizations.
Detects quota checks, title generation, prefix detection, safety classifier,
suggestion mode, and filepath extraction requests to enable targeted handling.
"""
from free_claude_code.core.anthropic import MessagesRequest, extract_text_from_content
def is_quota_check_request(request_data: MessagesRequest) -> bool:
"""Check if this is a quota probe request.
Quota checks are typically simple requests with max_tokens=1
and a single message containing the word "quota".
"""
if (
request_data.max_tokens == 1
and len(request_data.messages) == 1
and request_data.messages[0].role == "user"
):
text = extract_text_from_content(request_data.messages[0].content)
if "quota" in text.lower():
return True
return False
def is_title_generation_request(request_data: MessagesRequest) -> bool:
"""Check if this is a conversation title generation request.
Title generation requests are detected by a system prompt containing
title extraction instructions, no tools, and a single user message.
Matches Claude Code session title prompts (sentence-case title, JSON
\"title\" field, etc.).
"""
if not request_data.system or request_data.tools:
return False
system_text = extract_text_from_content(request_data.system).lower()
if "title" not in system_text:
return False
return "sentence-case title" in system_text or (
"return json" in system_text
and "field" in system_text
and ("coding session" in system_text or "this session" in system_text)
)
def is_prefix_detection_request(request_data: MessagesRequest) -> tuple[bool, str]:
"""Check if this is a fast prefix detection request.
Prefix detection requests contain a policy_spec block and
a Command: section for extracting shell command prefixes.
Returns:
Tuple of (is_prefix_request, command_string)
"""
if len(request_data.messages) != 1 or request_data.messages[0].role != "user":
return False, ""
content = extract_text_from_content(request_data.messages[0].content)
if "<policy_spec>" in content and "Command:" in content:
try:
cmd_start = content.rfind("Command:") + len("Command:")
return True, content[cmd_start:].strip()
except TypeError:
return False, ""
return False, ""
def is_safety_classifier_request(request_data: MessagesRequest) -> bool:
"""Return whether this is Claude Code's auto-mode safety classifier prompt."""
if request_data.tools:
return False
system_text = (
extract_text_from_content(request_data.system) if request_data.system else ""
)
messages_text = "".join(
extract_text_from_content(message.content) for message in request_data.messages
)
combined = f"{system_text}\n{messages_text}"
has_verdict_instruction = "yes</block>" in combined or "no</block>" in combined
return "<transcript>" in combined and has_verdict_instruction
def is_suggestion_mode_request(request_data: MessagesRequest) -> bool:
"""Check if this is a suggestion mode request.
Suggestion mode requests contain "[SUGGESTION MODE:" in the user's message,
used for auto-suggesting what the user might type next.
"""
for msg in request_data.messages:
if msg.role == "user":
text = extract_text_from_content(msg.content)
if "[SUGGESTION MODE:" in text:
return True
return False
def is_filepath_extraction_request(
request_data: MessagesRequest,
) -> tuple[bool, str, str]:
"""Check if this is a filepath extraction request.
Filepath extraction requests have a single user message with
"Command:" and "Output:" sections, asking to extract file paths
from command output.
Returns:
Tuple of (is_filepath_request, command, output)
"""
if len(request_data.messages) != 1 or request_data.messages[0].role != "user":
return False, "", ""
if request_data.tools:
return False, "", ""
content = extract_text_from_content(request_data.messages[0].content)
if "Command:" not in content or "Output:" not in content:
return False, "", ""
# Match if user content OR system block indicates filepath extraction
user_has_filepaths = (
"filepaths" in content.lower() or "<filepaths>" in content.lower()
)
system_text = (
extract_text_from_content(request_data.system) if request_data.system else ""
)
system_has_extract = (
"extract any file paths" in system_text.lower()
or "file paths that this command" in system_text.lower()
)
if not user_has_filepaths and not system_has_extract:
return False, "", ""
cmd_start = content.find("Command:") + len("Command:")
output_marker = content.find("Output:", cmd_start)
if output_marker == -1:
return False, "", ""
command = content[cmd_start:output_marker].strip()
output = content[output_marker + len("Output:") :].strip()
for marker in ["<", "\n\n"]:
if marker in output:
output = output.split(marker)[0].strip()
return True, command, output
@@ -0,0 +1,7 @@
"""Product-flow handlers for public API routes."""
from .messages import MessagesHandler
from .responses import ResponsesHandler
from .token_count import TokenCountHandler
__all__ = ["MessagesHandler", "ResponsesHandler", "TokenCountHandler"]
@@ -0,0 +1,358 @@
"""Claude Messages API product flow."""
import asyncio
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, replace
from fastapi.responses import JSONResponse, Response
from loguru import logger
from free_claude_code.api.detection import is_safety_classifier_request
from free_claude_code.api.optimization_handlers import try_optimizations
from free_claude_code.api.request_errors import (
http_status_for_unexpected_api_exception,
log_unexpected_api_exception,
require_non_empty_messages,
unexpected_http_exception,
)
from free_claude_code.api.request_ids import new_request_id
from free_claude_code.api.response_streams import (
EmptyStreamError,
anthropic_sse_streaming_response,
terminal_execution_error_response,
trace_terminal_execution_error,
)
from free_claude_code.api.web_tools.egress import (
WebFetchEgressPolicy,
web_fetch_allowed_scheme_set,
)
from free_claude_code.api.web_tools.request import (
is_web_server_tool_request,
unsupported_server_tool_error,
)
from free_claude_code.api.web_tools.streaming import stream_web_server_tool_response
from free_claude_code.application.errors import ApplicationError, InvalidRequestError
from free_claude_code.application.execution import ProviderExecutor, TokenCounter
from free_claude_code.application.ports import ProviderResolver
from free_claude_code.application.routing import ModelRouter, RoutedMessagesRequest
from free_claude_code.config.settings import Settings
from free_claude_code.core.anthropic import (
MessagesRequest,
aggregate_anthropic_sse_to_message,
anthropic_error_payload,
anthropic_error_type_for_failure,
anthropic_failure_payload,
anthropic_status_for_error_type,
get_token_count,
)
from free_claude_code.core.diagnostics import safe_exception_message
from free_claude_code.core.failures import ExecutionFailure, find_execution_failure
from free_claude_code.core.trace import trace_event
@dataclass(frozen=True)
class _MessagesStreamResult:
body: AsyncIterator[str]
@dataclass(frozen=True)
class _MessagesCompleteResult:
response: object
_MessagesResult = _MessagesStreamResult | _MessagesCompleteResult
MessageIntercept = Callable[[RoutedMessagesRequest], _MessagesResult | None]
class MessagesHandler:
"""Handle Anthropic-compatible Messages requests."""
def __init__(
self,
settings: Settings,
provider_resolver: ProviderResolver,
*,
model_router: ModelRouter | None = None,
token_counter: TokenCounter = get_token_count,
provider_executor: ProviderExecutor | None = None,
generation_id: int | None = None,
) -> None:
self._settings = settings
self._model_router = model_router or ModelRouter(settings)
self._token_counter = token_counter
self._provider_executor = provider_executor or ProviderExecutor(
provider_resolver,
token_counter=token_counter,
generation_id=generation_id,
log_raw_payloads=settings.log_raw_api_payloads,
)
self._message_intercepts: tuple[MessageIntercept, ...] = (
self._intercept_web_server_tool,
self._intercept_local_optimization,
)
async def create(
self, request_data: MessagesRequest, *, request_id: str | None = None
) -> object:
"""Create an Anthropic-compatible message response."""
request_id = request_id or new_request_id()
try:
require_non_empty_messages(request_data.messages)
routed = self._model_router.resolve_messages_request(request_data)
routed = self._apply_message_routing_policies(routed)
self._reject_unsupported_server_tools(routed)
result = self._run_message_intercepts(routed)
if result is None:
logger.debug("No optimization matched, routing to provider")
result = _MessagesStreamResult(
self._provider_executor.stream(
routed,
wire_api="messages",
raw_log_label="FULL_PAYLOAD",
raw_log_payload=routed.request.model_dump(),
request_id=request_id,
)
)
return await self._to_public_response(
result,
stream=request_data.stream,
request_id=request_id,
)
except ApplicationError:
raise
except ExecutionFailure as exc:
return self._execution_failure_response(exc, request_id=request_id)
except Exception as exc:
failure = find_execution_failure(exc)
if failure is not None:
return self._execution_failure_response(failure, request_id=request_id)
raise unexpected_http_exception(
self._settings, exc, context="CREATE_MESSAGE_ERROR"
) from exc
async def _to_public_response(
self,
result: _MessagesResult,
*,
stream: bool | None,
request_id: str,
) -> object:
if isinstance(result, _MessagesCompleteResult):
return result.response
if stream is False:
# Non-streaming clients (e.g. Claude Code utility calls) need a
# complete JSON Message; the internal pipeline is always SSE, so
# serving that raw here breaks the client SDK's response parse.
try:
message, error = await aggregate_anthropic_sse_to_message(result.body)
except GeneratorExit:
raise
except asyncio.CancelledError:
raise
except ExecutionFailure as exc:
return self._execution_failure_response(exc, request_id=request_id)
except BaseExceptionGroup as exc:
failure = find_execution_failure(exc)
if failure is not None:
return self._execution_failure_response(
failure, request_id=request_id
)
return self._unexpected_execution_error_response(
exc,
request_id=request_id,
context="CREATE_MESSAGE_NON_STREAM_ERROR",
)
except Exception as exc:
return self._unexpected_execution_error_response(
exc,
request_id=request_id,
context="CREATE_MESSAGE_NON_STREAM_ERROR",
)
if error is not None:
error_type, message_text = _stream_error_fields(error)
status_code = anthropic_status_for_error_type(error_type)
trace_terminal_execution_error(
wire_api="messages",
request_id=request_id,
status_code=status_code,
error_type=error_type,
)
return terminal_execution_error_response(
status_code=status_code,
content=anthropic_error_payload(
error_type=error_type,
message=message_text,
request_id=request_id,
),
)
return JSONResponse(content=message)
return await anthropic_sse_streaming_response(
result.body,
pre_start_error_response=lambda exc: self._pre_start_error_response(
exc, request_id=request_id
),
request_id=request_id,
)
def _pre_start_error_response(
self, exc: BaseException, *, request_id: str
) -> Response:
failure = find_execution_failure(exc)
if failure is not None:
return self._execution_failure_response(failure, request_id=request_id)
context = (
"CREATE_MESSAGE_EMPTY_STREAM"
if isinstance(exc, EmptyStreamError)
else "CREATE_MESSAGE_STREAM_START_ERROR"
)
return self._unexpected_execution_error_response(
exc,
request_id=request_id,
context=context,
)
def _execution_failure_response(
self, failure: ExecutionFailure, *, request_id: str
) -> JSONResponse:
error_type = anthropic_error_type_for_failure(failure)
trace_terminal_execution_error(
wire_api="messages",
request_id=request_id,
status_code=failure.status_code,
error_type=error_type,
error=failure,
)
return terminal_execution_error_response(
status_code=failure.status_code,
content=anthropic_failure_payload(failure, request_id=request_id),
)
def _unexpected_execution_error_response(
self,
exc: BaseException,
*,
request_id: str,
context: str,
) -> JSONResponse:
log_unexpected_api_exception(
self._settings,
exc,
context=context,
request_id=request_id,
)
status_code = http_status_for_unexpected_api_exception(exc)
trace_terminal_execution_error(
wire_api="messages",
request_id=request_id,
status_code=status_code,
error_type="api_error",
error=exc,
)
return terminal_execution_error_response(
status_code=status_code,
content=anthropic_error_payload(
error_type="api_error",
message=safe_exception_message(exc),
request_id=request_id,
),
)
def _reject_unsupported_server_tools(self, routed: RoutedMessagesRequest) -> None:
tool_err = unsupported_server_tool_error(
routed.request,
web_tools_enabled=self._settings.enable_web_server_tools,
)
if tool_err is not None:
raise InvalidRequestError(tool_err)
def _apply_message_routing_policies(
self, routed: RoutedMessagesRequest
) -> RoutedMessagesRequest:
if not is_safety_classifier_request(routed.request):
return routed
changed = routed.resolved.thinking_enabled
trace_event(
stage="routing",
event="free_claude_code.api.optimization.safety_classifier_no_thinking",
source="api",
model=routed.request.model,
changed=changed,
)
if not changed:
return routed
return RoutedMessagesRequest(
request=routed.request,
resolved=replace(routed.resolved, thinking_enabled=False),
)
def _run_message_intercepts(
self, routed: RoutedMessagesRequest
) -> _MessagesResult | None:
for intercept in self._message_intercepts:
result = intercept(routed)
if result is not None:
return result
return None
def _intercept_web_server_tool(
self, routed: RoutedMessagesRequest
) -> _MessagesResult | None:
if not self._settings.enable_web_server_tools:
return None
if not is_web_server_tool_request(routed.request):
return None
input_tokens = self._token_counter(
routed.request.messages, routed.request.system, routed.request.tools
)
trace_event(
stage="routing",
event="free_claude_code.api.optimization.web_server_tool",
source="api",
model=routed.request.model,
)
egress = WebFetchEgressPolicy(
allow_private_network_targets=self._settings.web_fetch_allow_private_networks,
allowed_schemes=web_fetch_allowed_scheme_set(
self._settings.web_fetch_allowed_schemes
),
)
return _MessagesStreamResult(
stream_web_server_tool_response(
routed.request,
input_tokens=input_tokens,
web_fetch_egress=egress,
verbose_client_errors=self._settings.log_api_error_tracebacks,
),
)
def _intercept_local_optimization(
self, routed: RoutedMessagesRequest
) -> _MessagesResult | None:
optimized = try_optimizations(routed.request, self._settings)
if optimized is None:
return None
trace_event(
stage="routing",
event="free_claude_code.api.optimization.short_circuit",
source="api",
model=routed.request.model,
)
return _MessagesCompleteResult(optimized)
def _stream_error_fields(error: dict[str, object]) -> tuple[str, str]:
raw_type = error.get("type")
error_type = (
raw_type.strip()
if isinstance(raw_type, str) and raw_type.strip()
else "api_error"
)
raw_message = error.get("message")
message = (
raw_message.strip()
if isinstance(raw_message, str) and raw_message.strip()
else "Provider request failed unexpectedly."
)
return error_type, message
@@ -0,0 +1,183 @@
"""OpenAI Responses API product flow for Codex clients."""
from fastapi.responses import JSONResponse
from free_claude_code.api.request_errors import (
http_status_for_unexpected_api_exception,
log_unexpected_api_exception,
require_non_empty_messages,
)
from free_claude_code.api.request_ids import new_request_id
from free_claude_code.api.response_streams import (
openai_responses_sse_streaming_response,
terminal_execution_error_response,
trace_terminal_execution_error,
)
from free_claude_code.application.errors import ApplicationError, InvalidRequestError
from free_claude_code.application.execution import ProviderExecutor
from free_claude_code.application.ports import ProviderResolver
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
from free_claude_code.core.anthropic import MessagesRequest
from free_claude_code.core.diagnostics import safe_exception_message
from free_claude_code.core.failures import ExecutionFailure, find_execution_failure
from free_claude_code.core.openai_responses import (
OpenAIResponsesAdapter,
OpenAIResponsesRequest,
openai_error_type_for_failure,
openai_failure_payload,
)
class ResponsesHandler:
"""Handle streaming OpenAI Responses-compatible requests."""
def __init__(
self,
settings: Settings,
provider_resolver: ProviderResolver,
*,
model_router: ModelRouter | None = None,
responses_adapter: OpenAIResponsesAdapter | None = None,
provider_executor: ProviderExecutor | None = None,
generation_id: int | None = None,
) -> None:
self._settings = settings
self._model_router = model_router or ModelRouter(settings)
self._responses_adapter = responses_adapter or OpenAIResponsesAdapter()
self._provider_executor = provider_executor or ProviderExecutor(
provider_resolver,
generation_id=generation_id,
log_raw_payloads=settings.log_raw_api_payloads,
)
async def create(
self, request_data: OpenAIResponsesRequest, *, request_id: str | None = None
) -> object:
"""Create a streaming OpenAI Responses-compatible response."""
request_id = request_id or new_request_id()
request_payload = request_data.model_dump(mode="json", exclude_none=True)
if request_data.stream is False:
raise InvalidRequestError(
"FCC /v1/responses supports streaming only; omit stream or set stream=true."
)
try:
anthropic_payload = self._responses_adapter.to_anthropic_payload(
request_data
)
response_request = MessagesRequest(**anthropic_payload)
require_non_empty_messages(response_request.messages)
routed = self._model_router.resolve_messages_request(response_request)
streamed = self._provider_executor.stream(
routed,
wire_api="responses",
raw_log_label="FULL_RESPONSES_PAYLOAD",
raw_log_payload=request_payload,
request_id=request_id,
)
return await openai_responses_sse_streaming_response(
self._responses_adapter.iter_sse_from_anthropic(
streamed,
request_data,
on_post_start_terminal_failure=lambda exc: (
self._trace_post_start_terminal_failure(
exc,
request_id=request_id,
)
),
),
headers=self._responses_adapter.sse_headers,
pre_start_error_response=lambda exc: self._pre_start_error_response(
exc, request_id=request_id
),
)
except OpenAIResponsesAdapter.ConversionError as exc:
raise InvalidRequestError(str(exc)) from exc
except ApplicationError:
raise
except ExecutionFailure as exc:
return self._execution_failure_response(exc, request_id=request_id)
except Exception as exc:
failure = find_execution_failure(exc)
if failure is not None:
return self._execution_failure_response(failure, request_id=request_id)
log_unexpected_api_exception(
self._settings,
exc,
context="CREATE_RESPONSE_ERROR",
)
return JSONResponse(
status_code=http_status_for_unexpected_api_exception(exc),
content=self._responses_adapter.error_payload(
message=safe_exception_message(exc),
error_type="api_error",
),
)
def _pre_start_error_response(
self, exc: BaseException, *, request_id: str
) -> JSONResponse:
failure = find_execution_failure(exc)
if failure is not None:
return self._execution_failure_response(failure, request_id=request_id)
log_unexpected_api_exception(
self._settings,
exc,
context="CREATE_RESPONSE_STREAM_START_ERROR",
request_id=request_id,
)
status_code = http_status_for_unexpected_api_exception(exc)
trace_terminal_execution_error(
wire_api="responses",
request_id=request_id,
status_code=status_code,
error_type="api_error",
error=exc,
)
return terminal_execution_error_response(
status_code=status_code,
content=self._responses_adapter.error_payload(
message=safe_exception_message(exc),
error_type="api_error",
),
)
def _execution_failure_response(
self,
failure: ExecutionFailure,
*,
request_id: str,
) -> JSONResponse:
error_type = openai_error_type_for_failure(failure)
trace_terminal_execution_error(
wire_api="responses",
request_id=request_id,
status_code=failure.status_code,
error_type=error_type,
error=failure,
)
return terminal_execution_error_response(
status_code=failure.status_code,
content=openai_failure_payload(failure),
)
@staticmethod
def _trace_post_start_terminal_failure(
exc: BaseException,
*,
request_id: str,
) -> None:
failure = find_execution_failure(exc)
trace_terminal_execution_error(
wire_api="responses",
request_id=request_id,
status_code=failure.status_code if failure is not None else 500,
error_type=(
openai_error_type_for_failure(failure)
if failure is not None
else "api_error"
),
error=exc,
)
@@ -0,0 +1,85 @@
"""Anthropic token-count API product flow."""
from fastapi import HTTPException
from loguru import logger
from free_claude_code.api.request_errors import (
http_status_for_unexpected_api_exception,
log_unexpected_api_exception,
require_non_empty_messages,
)
from free_claude_code.api.request_ids import new_request_id
from free_claude_code.application.errors import ApplicationError
from free_claude_code.application.execution import TokenCounter
from free_claude_code.application.routing import ModelRouter
from free_claude_code.config.settings import Settings
from free_claude_code.core.anthropic import (
TokenCountRequest,
TokenCountResponse,
anthropic_request_snapshot,
get_token_count,
)
from free_claude_code.core.diagnostics import safe_exception_message
from free_claude_code.core.trace import trace_event
class TokenCountHandler:
"""Handle Anthropic-compatible token count requests."""
def __init__(
self,
settings: Settings,
*,
model_router: ModelRouter | None = None,
token_counter: TokenCounter = get_token_count,
) -> None:
self._settings = settings
self._model_router = model_router or ModelRouter(settings)
self._token_counter = token_counter
def count(
self, request_data: TokenCountRequest, *, request_id: str | None = None
) -> TokenCountResponse:
"""Count tokens for a request after applying configured model routing."""
request_id = request_id or new_request_id()
with logger.contextualize(request_id=request_id):
try:
require_non_empty_messages(request_data.messages)
routed = self._model_router.resolve_token_count_request(request_data)
tokens = self._token_counter(
routed.request.messages, routed.request.system, routed.request.tools
)
trace_event(
stage="routing",
event="free_claude_code.api.route.resolved",
source="api",
request_id=request_id,
kind="count_tokens",
provider_id=routed.resolved.provider_id,
provider_model=routed.resolved.provider_model,
provider_model_ref=routed.resolved.provider_model_ref,
gateway_model=routed.request.model,
)
trace_event(
stage="ingress",
event="free_claude_code.api.count_tokens.completed",
source="api",
request_id=request_id,
message_count=len(routed.request.messages),
input_tokens=tokens,
snapshot=anthropic_request_snapshot(routed.request),
)
return TokenCountResponse(input_tokens=tokens)
except ApplicationError:
raise
except Exception as exc:
log_unexpected_api_exception(
self._settings,
exc,
context="COUNT_TOKENS_ERROR",
request_id=request_id,
)
raise HTTPException(
status_code=http_status_for_unexpected_api_exception(exc),
detail=safe_exception_message(exc),
) from exc
+152
View File
@@ -0,0 +1,152 @@
"""Model-list response construction for Claude-compatible clients."""
from typing import Literal
from pydantic import BaseModel
from free_claude_code.application.ports import RequestRuntimePort
from free_claude_code.config.model_refs import configured_chat_model_refs
from free_claude_code.config.settings import Settings
from free_claude_code.core.gateway_model_ids import (
gateway_model_id,
no_thinking_gateway_model_id,
)
DISCOVERED_MODEL_CREATED_AT = "1970-01-01T00:00:00Z"
class ModelResponse(BaseModel):
object: Literal["model"] = "model"
created: int = 0
owned_by: str = "free-claude-code"
created_at: str
display_name: str
id: str
type: Literal["model"] = "model"
class ModelsListResponse(BaseModel):
object: Literal["list"] = "list"
data: list[ModelResponse]
first_id: str | None
has_more: bool
last_id: str | None
SUPPORTED_CLAUDE_MODELS = [
ModelResponse(
id="claude-opus-4-20250514",
display_name="Claude Opus 4",
created_at="2025-05-14T00:00:00Z",
),
ModelResponse(
id="claude-sonnet-4-20250514",
display_name="Claude Sonnet 4",
created_at="2025-05-14T00:00:00Z",
),
ModelResponse(
id="claude-haiku-4-20250514",
display_name="Claude Haiku 4",
created_at="2025-05-14T00:00:00Z",
),
ModelResponse(
id="claude-3-opus-20240229",
display_name="Claude 3 Opus",
created_at="2024-02-29T00:00:00Z",
),
ModelResponse(
id="claude-3-5-sonnet-20241022",
display_name="Claude 3.5 Sonnet",
created_at="2024-10-22T00:00:00Z",
),
ModelResponse(
id="claude-3-haiku-20240307",
display_name="Claude 3 Haiku",
created_at="2024-03-07T00:00:00Z",
),
ModelResponse(
id="claude-3-5-haiku-20241022",
display_name="Claude 3.5 Haiku",
created_at="2024-10-22T00:00:00Z",
),
]
def build_models_list_response(
settings: Settings, runtime: RequestRuntimePort
) -> ModelsListResponse:
"""Return configured, cached, and compatibility model ids."""
models: list[ModelResponse] = []
seen: set[str] = set()
for ref in configured_chat_model_refs(settings):
supports_thinking = runtime.cached_model_supports_thinking(
ref.provider_id, ref.model_id
)
_append_provider_model_variants(
models,
seen,
ref.model_ref,
supports_thinking=supports_thinking,
)
for model_info in runtime.cached_prefixed_model_infos():
_append_provider_model_variants(
models,
seen,
model_info.model_id,
supports_thinking=model_info.supports_thinking,
)
for model in SUPPORTED_CLAUDE_MODELS:
_append_unique_model(models, seen, model)
return ModelsListResponse(
data=models,
first_id=models[0].id if models else None,
has_more=False,
last_id=models[-1].id if models else None,
)
def _discovered_model_response(model_id: str, *, display_name: str) -> ModelResponse:
return ModelResponse(
id=model_id,
display_name=display_name,
created_at=DISCOVERED_MODEL_CREATED_AT,
)
def _append_unique_model(
models: list[ModelResponse], seen: set[str], model: ModelResponse
) -> None:
if model.id in seen:
return
seen.add(model.id)
models.append(model)
def _append_provider_model_variants(
models: list[ModelResponse],
seen: set[str],
provider_model_ref: str,
*,
supports_thinking: bool | None = None,
) -> None:
if supports_thinking is not False:
_append_unique_model(
models,
seen,
_discovered_model_response(
gateway_model_id(provider_model_ref),
display_name=provider_model_ref,
),
)
_append_unique_model(
models,
seen,
_discovered_model_response(
no_thinking_gateway_model_id(provider_model_ref),
display_name=f"{provider_model_ref} (no thinking)",
),
)
@@ -0,0 +1,157 @@
"""Optimization handlers for fast-path API responses.
Each handler returns a MessagesResponse if the request matches and the
optimization is enabled, otherwise None.
"""
import uuid
from loguru import logger
from free_claude_code.config.settings import Settings
from free_claude_code.core.anthropic import (
MessagesRequest,
MessagesResponse,
Usage,
)
from .command_utils import extract_command_prefix, extract_filepaths_from_command
from .detection import (
is_filepath_extraction_request,
is_prefix_detection_request,
is_quota_check_request,
is_suggestion_mode_request,
is_title_generation_request,
)
def _text_response(
request_data: MessagesRequest,
text: str,
*,
input_tokens: int,
output_tokens: int,
) -> MessagesResponse:
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
content=[{"type": "text", "text": text}],
stop_reason="end_turn",
usage=Usage(input_tokens=input_tokens, output_tokens=output_tokens),
)
def try_prefix_detection(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
"""Fast prefix detection - return command prefix without API call."""
if not settings.fast_prefix_detection:
return None
is_prefix_req, command = is_prefix_detection_request(request_data)
if not is_prefix_req:
return None
logger.info("Optimization: Fast prefix detection request")
return _text_response(
request_data,
extract_command_prefix(command),
input_tokens=100,
output_tokens=5,
)
def try_quota_mock(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
"""Mock quota probe requests."""
if not settings.enable_network_probe_mock:
return None
if not is_quota_check_request(request_data):
return None
logger.info("Optimization: Intercepted and mocked quota probe")
return _text_response(
request_data,
"Quota check passed.",
input_tokens=10,
output_tokens=5,
)
def try_title_skip(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
"""Skip title generation requests."""
if not settings.enable_title_generation_skip:
return None
if not is_title_generation_request(request_data):
return None
logger.info("Optimization: Skipped title generation request")
return _text_response(
request_data,
"Conversation",
input_tokens=100,
output_tokens=5,
)
def try_suggestion_skip(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
"""Skip suggestion mode requests."""
if not settings.enable_suggestion_mode_skip:
return None
if not is_suggestion_mode_request(request_data):
return None
logger.info("Optimization: Skipped suggestion mode request")
return _text_response(
request_data,
"",
input_tokens=100,
output_tokens=1,
)
def try_filepath_mock(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
"""Mock filepath extraction requests."""
if not settings.enable_filepath_extraction_mock:
return None
is_fp, cmd, output = is_filepath_extraction_request(request_data)
if not is_fp:
return None
filepaths = extract_filepaths_from_command(cmd, output)
logger.info("Optimization: Mocked filepath extraction")
return _text_response(
request_data,
filepaths,
input_tokens=100,
output_tokens=10,
)
# Cheapest/most common optimizations first for faster short-circuit.
OPTIMIZATION_HANDLERS = [
try_quota_mock,
try_prefix_detection,
try_title_skip,
try_suggestion_skip,
try_filepath_mock,
]
def try_optimizations(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
"""Run optimization handlers in order. Returns first match or None."""
for handler in OPTIMIZATION_HANDLERS:
result = handler(request_data, settings)
if result is not None:
return result
return None
+32
View File
@@ -0,0 +1,32 @@
"""Runtime capabilities consumed by the HTTP API adapter."""
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Protocol
from free_claude_code.application.ports import RequestRuntimePort, TaskController
class AdminRuntimePort(Protocol):
"""Runtime operations exposed by the local Admin API."""
async def apply_admin_config(
self, updates: Mapping[str, Any]
) -> dict[str, Any]: ...
def admin_status(self) -> dict[str, Any]: ...
async def test_provider(self, provider_id: str) -> dict[str, Any]: ...
async def refresh_models(self) -> dict[str, Any]: ...
async def request_restart(self) -> None: ...
@dataclass(frozen=True, slots=True)
class ApiServices:
"""Complete runtime boundary required to construct the API application."""
requests: RequestRuntimePort
admin: AdminRuntimePort
tasks: TaskController
@@ -0,0 +1,99 @@
"""Shared API request validation and safe error logging."""
from typing import Any, Literal
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from loguru import logger
from free_claude_code.application.errors import ApplicationError, InvalidRequestError
from free_claude_code.config.settings import Settings
from free_claude_code.core.anthropic import (
anthropic_error_payload,
anthropic_error_type_for_failure,
)
from free_claude_code.core.diagnostics import (
redacted_exception_traceback,
safe_exception_message,
)
from free_claude_code.core.openai_responses import (
openai_error_payload,
openai_error_type_for_failure,
)
WireApi = Literal["messages", "responses"]
def require_non_empty_messages(messages: list[Any]) -> None:
if not messages:
raise InvalidRequestError("messages cannot be empty")
def ordinary_application_error_response(
error: ApplicationError,
*,
wire_api: WireApi,
request_id: str,
) -> JSONResponse:
"""Serialize a deterministic application error without terminal headers."""
if wire_api == "responses":
return JSONResponse(
status_code=error.status_code,
content=openai_error_payload(
message=error.message,
error_type=openai_error_type_for_failure(error.kind),
),
)
return JSONResponse(
status_code=error.status_code,
content=anthropic_error_payload(
error_type=anthropic_error_type_for_failure(error.kind),
message=error.message,
request_id=request_id,
),
)
def http_status_for_unexpected_api_exception(_exc: BaseException) -> int:
return 500
def log_unexpected_api_exception(
settings: Settings,
exc: BaseException,
*,
context: str,
request_id: str | None = None,
) -> None:
"""Log API failures without echoing exception text unless opted in."""
if settings.log_api_error_tracebacks:
if request_id is not None:
logger.error(
"{} request_id={}: {}",
context,
request_id,
safe_exception_message(exc),
)
else:
logger.error("{}: {}", context, safe_exception_message(exc))
logger.error(redacted_exception_traceback(exc))
return
if request_id is not None:
logger.error(
"{} request_id={} exc_type={}",
context,
request_id,
type(exc).__name__,
)
else:
logger.error("{} exc_type={}", context, type(exc).__name__)
def unexpected_http_exception(
settings: Settings, exc: Exception, *, context: str
) -> HTTPException:
log_unexpected_api_exception(settings, exc, context=context)
return HTTPException(
status_code=http_status_for_unexpected_api_exception(exc),
detail=safe_exception_message(exc),
)
+98
View File
@@ -0,0 +1,98 @@
"""Ingress-owned HTTP request correlation."""
import uuid
from fastapi import Request, Response
from loguru import logger
from starlette.datastructures import Headers, MutableHeaders
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from free_claude_code.core.trace import extract_claude_session_id_from_headers
REQUEST_ID_HEADER = "request-id"
OPENAI_REQUEST_ID_HEADER = "x-request-id"
_REQUEST_ID_STATE_ATTRIBUTE = "fcc_request_id"
_OPENAI_REQUEST_ID_PATHS = frozenset({"/v1/responses", "/v1/models"})
class RequestCorrelationMiddleware:
"""Own one request id and logging context for the full ASGI response."""
def __init__(self, app: ASGIApp) -> None:
self._app = app
async def __call__(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
if scope["type"] != "http":
await self._app(scope, receive, send)
return
request_id = new_request_id()
state = scope.setdefault("state", {})
state[_REQUEST_ID_STATE_ATTRIBUTE] = request_id
method = scope.get("method", "")
path = scope.get("path", "")
request_headers = Headers(scope=scope)
claude_sid = extract_claude_session_id_from_headers(request_headers)
async def send_with_correlation(message: Message) -> None:
if message["type"] == "http.response.start":
message = dict(message)
raw_headers = list(message.get("headers", ()))
_set_request_id_headers(
MutableHeaders(raw=raw_headers),
request_id=request_id,
path=path,
)
message["headers"] = raw_headers
await send(message)
with logger.contextualize(
http_method=method,
http_path=path,
claude_session_id=claude_sid,
request_id=request_id,
):
await self._app(scope, receive, send_with_correlation)
def new_request_id() -> str:
"""Return a new opaque FCC request identifier."""
return f"req_{uuid.uuid4().hex}"
def set_request_id(request: Request, request_id: str) -> None:
"""Attach the ingress correlation identifier to request state."""
setattr(request.state, _REQUEST_ID_STATE_ATTRIBUTE, request_id)
def get_request_id(request: Request) -> str:
"""Return the ingress correlation identifier, creating a fallback if needed."""
request_id = getattr(request.state, _REQUEST_ID_STATE_ATTRIBUTE, None)
if isinstance(request_id, str) and request_id:
return request_id
request_id = new_request_id()
set_request_id(request, request_id)
return request_id
def attach_request_id_headers(
response: Response, *, request_id: str, path: str
) -> None:
"""Attach correlation when an outer server-error boundary bypasses middleware."""
_set_request_id_headers(response.headers, request_id=request_id, path=path)
def _set_request_id_headers(
headers: MutableHeaders,
*,
request_id: str,
path: str,
) -> None:
headers[REQUEST_ID_HEADER] = request_id
if path in _OPENAI_REQUEST_ID_PATHS:
headers[OPENAI_REQUEST_ID_HEADER] = request_id
@@ -0,0 +1,386 @@
"""FastAPI streaming response wrappers for public API wire formats."""
import asyncio
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Mapping,
)
from typing import Any, Literal
from fastapi.responses import JSONResponse, Response, StreamingResponse
from starlette.background import BackgroundTask
from starlette.responses import ContentStream
from starlette.types import Receive, Scope, Send
from free_claude_code.core.anthropic import anthropic_error_type_for_failure
from free_claude_code.core.anthropic.streaming import (
ANTHROPIC_SSE_RESPONSE_HEADERS,
anthropic_terminal_error_frame,
anthropic_terminal_failure_frame,
)
from free_claude_code.core.async_iterators import try_close_async_iterator
from free_claude_code.core.diagnostics import safe_exception_message
from free_claude_code.core.failures import find_execution_failure
from free_claude_code.core.trace import close_stream_input, trace_event
TERMINAL_EXECUTION_ERROR_HEADERS = {"x-should-retry": "false"}
PreStartErrorResponse = Callable[[BaseException], Response]
TerminalFrameEmitter = Callable[[BaseException], str]
TerminalFailureObserver = Callable[[BaseException], None]
ReleaseResponseResource = Callable[[], Awaitable[None]]
WireApi = Literal["messages", "responses"]
class EmptyStreamError(RuntimeError):
"""Raised when a public stream ends before emitting any protocol chunk."""
class ManagedStreamingResponse(StreamingResponse):
"""Own body closure and one response-scoped runtime release callback."""
def __init__(
self,
content: ContentStream,
status_code: int = 200,
headers: Mapping[str, str] | None = None,
media_type: str | None = None,
background: BackgroundTask | None = None,
) -> None:
super().__init__(
content,
status_code=status_code,
headers=headers,
media_type=media_type,
background=background,
)
self._release: ReleaseResponseResource | None = None
self._cleanup_task: asyncio.Task[None] | None = None
def bind_release(self, release: ReleaseResponseResource) -> None:
"""Bind the resource retained for this response before ASGI execution."""
if self._release is not None:
raise RuntimeError("A response resource release is already bound.")
if self._cleanup_task is not None:
raise RuntimeError("Cannot bind a resource after response cleanup started.")
self._release = release
async def aclose(self) -> None:
"""Close the body and release its runtime resource exactly once."""
await self._close(preserved_error=None)
async def _close(self, *, preserved_error: BaseException | None) -> None:
task = self._cleanup_task
if task is None:
task = asyncio.create_task(
self._cleanup(preserved_error=preserved_error),
name="fcc-api-response-cleanup",
)
self._cleanup_task = task
await _wait_for_cleanup(task)
async def __call__(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
preserved_error: BaseException | None = None
try:
await super().__call__(scope, receive, send)
except BaseException as exc:
preserved_error = exc
raise
finally:
await self._close(preserved_error=preserved_error)
async def _cleanup(self, *, preserved_error: BaseException | None) -> None:
try:
await close_stream_input(
self.body_iterator,
owner="ManagedStreamingResponse",
source="api",
preserved_error=preserved_error,
)
except Exception as exc:
_trace_response_cleanup_failure("close_body", exc)
release = self._release
if release is None:
return
try:
await release()
except Exception as exc:
_trace_response_cleanup_failure("release_resource", exc)
async def _wait_for_cleanup(task: asyncio.Task[None]) -> None:
"""Wait through repeated caller cancellation, then restore cancellation."""
cancellation: asyncio.CancelledError | None = None
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError as exc:
cancellation = exc
# Ordinary defensive failures are trace-only; cancellation remains control flow.
try:
task.result()
except asyncio.CancelledError:
if cancellation is not None:
raise cancellation from None
raise
except Exception as exc:
_trace_response_cleanup_failure("cleanup_task", exc)
if cancellation is not None:
raise cancellation
def _trace_response_cleanup_failure(operation: str, exc: BaseException) -> None:
trace_event(
stage="egress",
event="free_claude_code.api.response.cleanup_failed",
source="api",
operation=operation,
exc_type=type(exc).__name__,
)
async def bind_response_lifetime(
response: object,
release: ReleaseResponseResource,
) -> object:
"""Retain a runtime resource until a response body is fully consumed."""
if isinstance(response, ManagedStreamingResponse):
response.bind_release(release)
return response
if isinstance(response, StreamingResponse):
error = TypeError("Streaming API responses must use ManagedStreamingResponse.")
try:
await close_stream_input(
response.body_iterator,
owner="bind_response_lifetime",
source="api",
preserved_error=error,
)
finally:
await release()
raise error
await release()
return response
def terminal_execution_error_response(
*, status_code: int, content: dict[str, Any]
) -> JSONResponse:
"""Return a final provider-execution error without enabling client retries."""
return JSONResponse(
status_code=status_code,
content=content,
headers=dict(TERMINAL_EXECUTION_ERROR_HEADERS),
)
def trace_terminal_execution_error(
*,
wire_api: WireApi,
request_id: str,
status_code: int,
error_type: str,
error: BaseException | None = None,
) -> None:
"""Record one correlated terminal-execution decision at the HTTP boundary."""
fields: dict[str, object] = {
"stage": "egress",
"event": "free_claude_code.api.response.terminal_execution_error",
"source": "api",
"wire_api": wire_api,
"request_id": request_id,
"status_code": status_code,
"error_type": error_type,
"client_should_retry": False,
}
failure = find_execution_failure(error) if error is not None else None
if error is not None:
fields["exc_type"] = type(failure or error).__name__
if failure is not None:
fields["failure_kind"] = failure.kind.value
fields["provider_retryable"] = failure.retryable
trace_event(**fields)
async def _first_chunk_streaming_response(
body: AsyncIterator[str],
*,
headers: Mapping[str, str],
pre_start_error_response: PreStartErrorResponse,
terminal_frame: TerminalFrameEmitter | None,
terminal_failure_observer: TerminalFailureObserver | None,
) -> Response:
try:
first_chunk = await anext(body)
except StopAsyncIteration:
error = EmptyStreamError("Stream ended before emitting a response.")
await _close_pre_start_body(body, preserved_error=error)
return pre_start_error_response(error)
except GeneratorExit as exc:
await _close_pre_start_body(body, preserved_error=exc)
raise
except asyncio.CancelledError as exc:
await _close_pre_start_body(body, preserved_error=exc)
raise
except BaseExceptionGroup as exc:
await _close_pre_start_body(body, preserved_error=exc)
return pre_start_error_response(exc)
except Exception as exc:
await _close_pre_start_body(body, preserved_error=exc)
return pre_start_error_response(exc)
return ManagedStreamingResponse(
_PrefetchedStream(
first_chunk,
body,
terminal_frame=terminal_frame,
terminal_failure_observer=terminal_failure_observer,
),
media_type="text/event-stream",
headers=dict(headers),
)
async def _close_pre_start_body(
body: AsyncIterator[str],
*,
preserved_error: BaseException,
) -> None:
task = asyncio.create_task(
close_stream_input(
body,
owner="first_chunk_streaming_response",
source="api",
preserved_error=preserved_error,
),
name="fcc-api-pre-start-stream-cleanup",
)
await _wait_for_cleanup(task)
class _PrefetchedStream(AsyncIterator[str]):
"""Replay one prefetched frame while retaining ownership of the tail."""
def __init__(
self,
first_chunk: str,
body: AsyncIterator[str],
*,
terminal_frame: TerminalFrameEmitter | None,
terminal_failure_observer: TerminalFailureObserver | None,
) -> None:
self._first_chunk: str | None = first_chunk
self._body = body
self._terminal_frame = terminal_frame
self._terminal_failure_observer = terminal_failure_observer
self._done = False
self._closed = False
def __aiter__(self) -> _PrefetchedStream:
return self
async def __anext__(self) -> str:
if self._closed or self._done:
raise StopAsyncIteration
if self._first_chunk is not None:
first_chunk = self._first_chunk
self._first_chunk = None
return first_chunk
try:
return await anext(self._body)
except StopAsyncIteration:
self._done = True
raise
except BaseExceptionGroup as exc:
return self._terminal_chunk(find_execution_failure(exc) or exc)
except Exception as exc:
return self._terminal_chunk(exc)
async def aclose(self) -> None:
if self._closed:
return
self._closed = True
self._done = True
close_error = await try_close_async_iterator(self._body)
if close_error is not None:
raise close_error
def _terminal_chunk(self, exc: BaseException) -> str:
terminal_frame = self._terminal_frame
if terminal_frame is None:
raise exc
self._done = True
if self._terminal_failure_observer is not None:
self._terminal_failure_observer(exc)
return terminal_frame(exc)
async def anthropic_sse_streaming_response(
body: AsyncIterator[str],
*,
pre_start_error_response: PreStartErrorResponse,
request_id: str,
) -> Response:
"""Return a streaming response for Anthropic-style SSE streams."""
return await _first_chunk_streaming_response(
body,
headers=ANTHROPIC_SSE_RESPONSE_HEADERS,
pre_start_error_response=pre_start_error_response,
terminal_frame=_anthropic_terminal_frame,
terminal_failure_observer=lambda exc: _trace_anthropic_terminal_failure(
exc,
request_id=request_id,
),
)
def _anthropic_terminal_frame(exc: BaseException) -> str:
failure = find_execution_failure(exc)
if failure is not None:
return anthropic_terminal_failure_frame(failure)
return anthropic_terminal_error_frame(safe_exception_message(exc))
def _trace_anthropic_terminal_failure(
exc: BaseException,
*,
request_id: str,
) -> None:
failure = find_execution_failure(exc)
trace_terminal_execution_error(
wire_api="messages",
request_id=request_id,
status_code=failure.status_code if failure is not None else 500,
error_type=(
anthropic_error_type_for_failure(failure)
if failure is not None
else "api_error"
),
error=exc,
)
async def openai_responses_sse_streaming_response(
body: AsyncIterator[str],
*,
headers: Mapping[str, str],
pre_start_error_response: PreStartErrorResponse,
) -> Response:
"""Return a streaming response for OpenAI Responses-style SSE."""
return await _first_chunk_streaming_response(
body,
headers=headers,
pre_start_error_response=pre_start_error_response,
terminal_frame=None,
terminal_failure_observer=None,
)
+221
View File
@@ -0,0 +1,221 @@
"""FastAPI route handlers."""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from loguru import logger
from free_claude_code.application.errors import ApplicationError
from free_claude_code.application.ports import ProviderResolver, RequestRuntimeLease
from free_claude_code.config.model_refs import parse_provider_type
from free_claude_code.config.settings import Settings
from free_claude_code.core.anthropic import (
MessagesRequest,
TokenCountRequest,
get_token_count,
)
from free_claude_code.core.openai_responses import OpenAIResponsesRequest
from free_claude_code.core.trace import trace_event
from .dependencies import (
get_services,
get_settings,
require_api_key,
resolve_provider,
)
from .handlers import MessagesHandler, ResponsesHandler, TokenCountHandler
from .model_catalog import ModelsListResponse, build_models_list_response
from .ports import ApiServices
from .request_errors import ordinary_application_error_response
from .request_ids import get_request_id
from .response_streams import bind_response_lifetime
router = APIRouter()
def _provider_resolver(lease: RequestRuntimeLease) -> ProviderResolver:
return lambda provider_type: resolve_provider(provider_type, lease=lease)
async def _create_messages_response(
services: ApiServices,
request_data: MessagesRequest,
*,
request_id: str,
) -> object:
lease: RequestRuntimeLease | None = None
try:
lease = await services.requests.acquire()
handler = MessagesHandler(
lease.settings,
provider_resolver=_provider_resolver(lease),
token_counter=get_token_count,
generation_id=lease.generation_id,
)
response = await handler.create(request_data, request_id=request_id)
except ApplicationError as exc:
if lease is not None:
await lease.release()
return ordinary_application_error_response(
exc,
wire_api="messages",
request_id=request_id,
)
except BaseException:
if lease is not None:
await lease.release()
raise
assert lease is not None
return await bind_response_lifetime(response, lease.release)
async def _create_responses_response(
services: ApiServices,
request_data: OpenAIResponsesRequest,
*,
request_id: str,
) -> object:
lease: RequestRuntimeLease | None = None
try:
lease = await services.requests.acquire()
handler = ResponsesHandler(
lease.settings,
provider_resolver=_provider_resolver(lease),
generation_id=lease.generation_id,
)
response = await handler.create(request_data, request_id=request_id)
except ApplicationError as exc:
if lease is not None:
await lease.release()
return ordinary_application_error_response(
exc,
wire_api="responses",
request_id=request_id,
)
except BaseException:
if lease is not None:
await lease.release()
raise
assert lease is not None
return await bind_response_lifetime(response, lease.release)
def _probe_response(allow: str) -> Response:
return Response(status_code=204, headers={"Allow": allow})
@router.post("/v1/messages")
async def create_message(
request: Request,
request_data: MessagesRequest,
services: ApiServices = Depends(get_services),
_auth=Depends(require_api_key),
):
"""Create a message (streaming by default; stream=false gets aggregated JSON)."""
return await _create_messages_response(
services,
request_data,
request_id=get_request_id(request),
)
@router.api_route("/v1/messages", methods=["HEAD", "OPTIONS"])
async def probe_messages(_auth=Depends(require_api_key)):
return _probe_response("POST, HEAD, OPTIONS")
@router.post("/v1/responses")
async def create_response(
request: Request,
request_data: OpenAIResponsesRequest,
services: ApiServices = Depends(get_services),
_auth=Depends(require_api_key),
):
"""Create an OpenAI Responses-compatible response through this proxy."""
return await _create_responses_response(
services,
request_data,
request_id=get_request_id(request),
)
@router.api_route("/v1/responses", methods=["HEAD", "OPTIONS"])
async def probe_responses(_auth=Depends(require_api_key)):
return _probe_response("POST, HEAD, OPTIONS")
@router.post("/v1/messages/count_tokens")
async def count_tokens(
request: Request,
request_data: TokenCountRequest,
settings: Settings = Depends(get_settings),
_auth=Depends(require_api_key),
):
"""Count tokens for a request."""
handler = TokenCountHandler(settings, token_counter=get_token_count)
return handler.count(request_data, request_id=get_request_id(request))
@router.api_route("/v1/messages/count_tokens", methods=["HEAD", "OPTIONS"])
async def probe_count_tokens(_auth=Depends(require_api_key)):
return _probe_response("POST, HEAD, OPTIONS")
@router.get("/")
async def root(
settings: Settings = Depends(get_settings),
_auth=Depends(require_api_key),
):
return {
"status": "ok",
"provider": parse_provider_type(settings.model),
"model": settings.model,
}
@router.api_route("/", methods=["HEAD", "OPTIONS"])
async def probe_root():
return _probe_response("GET, HEAD, OPTIONS")
@router.get("/health")
async def health():
return {"status": "healthy"}
@router.api_route("/health", methods=["HEAD", "OPTIONS"])
async def probe_health():
return _probe_response("GET, HEAD, OPTIONS")
@router.get("/v1/models", response_model=ModelsListResponse)
async def list_models(
services: ApiServices = Depends(get_services),
settings: Settings = Depends(get_settings),
_auth=Depends(require_api_key),
):
"""List the model ids this proxy advertises to compatible clients."""
trace_event(stage="ingress", event="free_claude_code.api.models.list", source="api")
return build_models_list_response(settings, services.requests)
@router.post("/stop")
async def stop_cli(
services: ApiServices = Depends(get_services),
_auth=Depends(require_api_key),
):
"""Stop all CLI sessions and pending tasks."""
result = await services.tasks.stop_all()
if result is None:
raise HTTPException(status_code=503, detail="Messaging system not initialized")
if result.source is not None:
logger.info("STOP_CLI: source={} cancelled_count=N/A", result.source)
return {"status": "stopped", "source": result.source}
count = result.cancelled_count or 0
trace_event(
stage="ingress",
event="free_claude_code.api.cli.stop_via_messaging_workflow",
source="api",
cancelled_nodes=count,
)
logger.info("STOP_CLI: source=messaging_workflow cancelled_count={}", count)
return {"status": "stopped", "cancelled_count": count}
@@ -0,0 +1,46 @@
"""Safe metadata summaries for HTTP 422 validation logging (no raw text content)."""
from typing import Any
def summarize_request_validation_body(
body: Any,
) -> tuple[list[dict[str, Any]], list[str]]:
"""Return message shape summary and tool name list for debug logs."""
messages = body.get("messages") if isinstance(body, dict) else None
message_summary: list[dict[str, Any]] = []
if isinstance(messages, list):
for msg in messages:
if not isinstance(msg, dict):
message_summary.append({"message_kind": type(msg).__name__})
continue
content = msg.get("content")
item: dict[str, Any] = {
"role": msg.get("role"),
"content_kind": type(content).__name__,
}
if isinstance(content, list):
item["block_types"] = [
block.get("type", "dict")
if isinstance(block, dict)
else type(block).__name__
for block in content[:12]
]
item["block_keys"] = [
sorted(str(key) for key in block)[:12]
for block in content[:5]
if isinstance(block, dict)
]
elif isinstance(content, str):
item["content_length"] = len(content)
message_summary.append(item)
tool_names: list[str] = []
if isinstance(body, dict) and isinstance(body.get("tools"), list):
tool_names = [
str(tool.get("name", ""))
for tool in body["tools"]
if isinstance(tool, dict)
]
return message_summary, tool_names
@@ -0,0 +1,17 @@
"""Submodules for Anthropic web server tool handling (search/fetch, egress, streaming)."""
from .egress import (
WebFetchEgressPolicy,
WebFetchEgressViolation,
enforce_web_fetch_egress,
)
from .request import is_web_server_tool_request
from .streaming import stream_web_server_tool_response
__all__ = [
"WebFetchEgressPolicy",
"WebFetchEgressViolation",
"enforce_web_fetch_egress",
"is_web_server_tool_request",
"stream_web_server_tool_response",
]
@@ -0,0 +1,17 @@
"""Limits and defaults for outbound web server tool HTTP."""
from free_claude_code.core.version import package_version
_REQUEST_TIMEOUT_S = 20.0
_MAX_SEARCH_RESULTS = 10
_MAX_FETCH_CHARS = 24_000
# Hard cap on raw bytes read from HTTP responses before decode / HTML parse (memory bound).
_MAX_WEB_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024
# Drain at most this many bytes from redirect responses before following Location.
_REDIRECT_RESPONSE_BODY_CAP_BYTES = 65_536
_MAX_WEB_FETCH_REDIRECTS = 10
_WEB_FETCH_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
_WEB_TOOL_HTTP_HEADERS = {
"User-Agent": f"Mozilla/5.0 compatible; free-claude-code/{package_version()}",
}
@@ -0,0 +1,105 @@
"""Egress policy for user-controlled web_fetch URLs (SSRF guard)."""
import ipaddress
import socket
from dataclasses import dataclass
from urllib.parse import urlparse
@dataclass(frozen=True, slots=True)
class WebFetchEgressPolicy:
"""Egress rules for user-influenced web_fetch URLs."""
allow_private_network_targets: bool
allowed_schemes: frozenset[str]
class WebFetchEgressViolation(ValueError):
"""Raised when a web_fetch URL is rejected by egress policy (SSRF guard)."""
def web_fetch_allowed_scheme_set(raw_schemes: str) -> frozenset[str]:
"""Return normalized schemes allowed for web_fetch."""
return frozenset(
part.strip().lower() for part in raw_schemes.split(",") if part.strip()
)
def _port_for_url(parsed) -> int:
if parsed.port is not None:
return parsed.port
return 443 if (parsed.scheme or "").lower() == "https" else 80
def _stream_getaddrinfo_or_raise(host: str, port: int) -> list[tuple]:
try:
return socket.getaddrinfo(
host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
)
except OSError as exc:
raise WebFetchEgressViolation(
f"Could not resolve host {host!r}: {exc}"
) from exc
def get_validated_stream_addrinfos_for_egress(
url: str, policy: WebFetchEgressPolicy
) -> list[tuple]:
"""Resolve and validate a URL for web_fetch, returning getaddrinfo rows for pinning.
Each HTTP connect pins to only these `getaddrinfo` results so a malicious DNS
server cannot rebind to a disallowed address between resolution and the TCP
connect (used by :func:`api.web_tools.outbound._run_web_fetch`).
"""
parsed = urlparse(url)
scheme = (parsed.scheme or "").lower()
if scheme not in policy.allowed_schemes:
raise WebFetchEgressViolation(
f"URL scheme {scheme!r} is not allowed for web_fetch"
)
host = parsed.hostname
if host is None or host == "":
raise WebFetchEgressViolation("web_fetch URL must include a host")
port = _port_for_url(parsed)
if policy.allow_private_network_targets:
return _stream_getaddrinfo_or_raise(host, port)
host_lower = host.lower()
if host_lower == "localhost" or host_lower.endswith(".localhost"):
raise WebFetchEgressViolation("localhost targets are not allowed for web_fetch")
if host_lower.endswith(".local"):
raise WebFetchEgressViolation(".local hostnames are not allowed for web_fetch")
try:
parsed_ip = ipaddress.ip_address(host)
except ValueError:
parsed_ip = None
if parsed_ip is not None:
if not parsed_ip.is_global:
raise WebFetchEgressViolation(
f"Non-public IP host {host!r} is not allowed for web_fetch"
)
return _stream_getaddrinfo_or_raise(host, port)
infos = _stream_getaddrinfo_or_raise(host, port)
for *_, sockaddr in infos:
addr = sockaddr[0]
try:
resolved = ipaddress.ip_address(addr)
except ValueError:
continue
if not resolved.is_global:
raise WebFetchEgressViolation(
f"Host {host!r} resolves to a non-public address ({resolved})"
)
return infos
def enforce_web_fetch_egress(url: str, policy: WebFetchEgressPolicy) -> None:
"""Validate ``url`` (scheme, host, and resolved addresses) for web_fetch."""
get_validated_stream_addrinfos_for_egress(url, policy)
@@ -0,0 +1,276 @@
"""Outbound HTTP for web_search / web_fetch (client, body caps, logging)."""
import asyncio
import socket
from collections.abc import AsyncIterator
from urllib.parse import urljoin, urlparse
import aiohttp
import httpx
from aiohttp import ClientSession, ClientTimeout, TCPConnector
from aiohttp.abc import AbstractResolver, ResolveResult
from loguru import logger
from . import constants
from .constants import (
_MAX_FETCH_CHARS,
_MAX_SEARCH_RESULTS,
_REDIRECT_RESPONSE_BODY_CAP_BYTES,
_REQUEST_TIMEOUT_S,
_WEB_FETCH_REDIRECT_STATUSES,
_WEB_TOOL_HTTP_HEADERS,
)
from .egress import (
WebFetchEgressPolicy,
WebFetchEgressViolation,
get_validated_stream_addrinfos_for_egress,
)
from .parsers import HTMLTextParser, SearchResultParser
def _safe_public_host_for_logs(url: str) -> str:
host = urlparse(url).hostname or ""
return host[:253]
def _log_web_tool_failure(
tool_name: str,
error: BaseException,
*,
fetch_url: str | None = None,
) -> None:
exc_type = type(error).__name__
if isinstance(error, WebFetchEgressViolation):
host = _safe_public_host_for_logs(fetch_url) if fetch_url else ""
logger.warning(
"web_tool_egress_rejected tool={} exc_type={} host={!r}",
tool_name,
exc_type,
host,
)
return
if tool_name == "web_fetch" and fetch_url:
logger.warning(
"web_tool_failure tool={} exc_type={} host={!r}",
tool_name,
exc_type,
_safe_public_host_for_logs(fetch_url),
)
else:
logger.warning("web_tool_failure tool={} exc_type={}", tool_name, exc_type)
def _web_tool_client_error_summary(
tool_name: str,
error: BaseException,
*,
verbose: bool,
) -> str:
if verbose:
return f"{tool_name} failed: {type(error).__name__}"
return "Web tool request failed."
async def _iter_response_body_under_cap(
response: httpx.Response, max_bytes: int
) -> AsyncIterator[bytes]:
if max_bytes <= 0:
return
received = 0
async for chunk in response.aiter_bytes(chunk_size=65_536):
if received >= max_bytes:
break
remaining = max_bytes - received
if len(chunk) <= remaining:
received += len(chunk)
yield chunk
if received >= max_bytes:
break
else:
yield chunk[:remaining]
break
async def _drain_response_body_capped(response: httpx.Response, max_bytes: int) -> None:
async for _ in _iter_response_body_under_cap(response, max_bytes):
pass
async def _read_response_body_capped(response: httpx.Response, max_bytes: int) -> bytes:
return b"".join(
[piece async for piece in _iter_response_body_under_cap(response, max_bytes)]
)
_NUMERIC_RESOLVE_FLAGS = socket.AI_NUMERICHOST | socket.AI_NUMERICSERV
_NAME_RESOLVE_FLAGS = socket.NI_NUMERICHOST | socket.NI_NUMERICSERV
def getaddrinfo_rows_to_resolve_results(
host: str, addrinfos: list[tuple]
) -> list[ResolveResult]:
"""Map :func:`socket.getaddrinfo` rows to aiohttp :class:`ResolveResult` (ThreadedResolver logic)."""
out: list[ResolveResult] = []
for family, _type, proto, _canon, sockaddr in addrinfos:
if family == socket.AF_INET6:
if len(sockaddr) < 3:
continue
if sockaddr[3]:
resolved_host, port = socket.getnameinfo(sockaddr, _NAME_RESOLVE_FLAGS)
else:
resolved_host, port = sockaddr[:2]
else:
assert family == socket.AF_INET, family
resolved_host, port = sockaddr[0], sockaddr[1]
resolved_host = str(resolved_host)
port = int(port)
out.append(
ResolveResult(
hostname=host,
host=resolved_host,
port=int(port),
family=family,
proto=proto,
flags=_NUMERIC_RESOLVE_FLAGS,
)
)
return out
class _PinnedEgressStaticResolver(AbstractResolver):
"""Return only pre-validated :class:`ResolveResult` for the outbound request."""
def __init__(self, results: list[ResolveResult]) -> None:
self._results = results
async def resolve(
self, host: str, port: int = 0, family: int = socket.AF_INET
) -> list[ResolveResult]:
return self._results
async def close(self) -> None: # pragma: no cover - aiohttp contract
return
async def _read_aiohttp_body_capped(
response: aiohttp.ClientResponse, max_bytes: int
) -> bytes:
received = 0
parts: list[bytes] = []
async for chunk in response.content.iter_chunked(65_536):
if received >= max_bytes:
break
remaining = max_bytes - received
if len(chunk) <= remaining:
received += len(chunk)
parts.append(chunk)
else:
parts.append(chunk[:remaining])
break
return b"".join(parts)
async def _drain_aiohttp_body_capped(
response: aiohttp.ClientResponse, max_bytes: int
) -> None:
if max_bytes <= 0:
return
received = 0
async for chunk in response.content.iter_chunked(65_536):
received += len(chunk)
if received >= max_bytes:
break
async def _run_web_search(query: str) -> list[dict[str, str]]:
async with (
httpx.AsyncClient(
timeout=_REQUEST_TIMEOUT_S,
follow_redirects=True,
headers=_WEB_TOOL_HTTP_HEADERS,
) as client,
client.stream(
"GET",
"https://lite.duckduckgo.com/lite/",
params={"q": query},
) as response,
):
response.raise_for_status()
body_bytes = await _read_response_body_capped(
response, constants._MAX_WEB_FETCH_RESPONSE_BYTES
)
text = body_bytes.decode("utf-8", errors="replace")
parser = SearchResultParser()
parser.feed(text)
return parser.results[:_MAX_SEARCH_RESULTS]
async def _run_web_fetch(url: str, egress: WebFetchEgressPolicy) -> dict[str, str]:
"""Fetch URL with manual redirects; each hop is DNS-pinned to validated addresses."""
current_url = url
redirect_hops = 0
timeout = ClientTimeout(total=_REQUEST_TIMEOUT_S)
while True:
addr_infos = await asyncio.to_thread(
get_validated_stream_addrinfos_for_egress, current_url, egress
)
host = urlparse(current_url).hostname or ""
results = getaddrinfo_rows_to_resolve_results(host, addr_infos)
resolver = _PinnedEgressStaticResolver(results)
connector = TCPConnector(
resolver=resolver,
force_close=True,
)
try:
async with (
ClientSession(
timeout=timeout,
headers=_WEB_TOOL_HTTP_HEADERS,
connector=connector,
) as session,
session.get(current_url, allow_redirects=False) as response,
):
if response.status in _WEB_FETCH_REDIRECT_STATUSES:
await _drain_aiohttp_body_capped(
response, _REDIRECT_RESPONSE_BODY_CAP_BYTES
)
if redirect_hops >= constants._MAX_WEB_FETCH_REDIRECTS:
raise WebFetchEgressViolation(
"web_fetch exceeded maximum redirects "
f"({constants._MAX_WEB_FETCH_REDIRECTS})"
)
location = response.headers.get("location")
if not location or not location.strip():
raise WebFetchEgressViolation(
"web_fetch redirect response missing Location header"
)
current_url = urljoin(str(response.url), location.strip())
redirect_hops += 1
continue
response.raise_for_status()
content_type = response.headers.get("content-type", "text/plain")
final_url = str(response.url)
encoding = response.get_encoding() or "utf-8"
body_bytes = await _read_aiohttp_body_capped(
response, constants._MAX_WEB_FETCH_RESPONSE_BYTES
)
finally:
await connector.close()
break
text = body_bytes.decode(encoding, errors="replace")
title = final_url
data = text
if "html" in content_type.lower():
parser = HTMLTextParser()
parser.feed(text)
title = parser.title or final_url
data = "\n".join(parser.text_parts)
return {
"url": final_url,
"title": title,
"media_type": "text/plain",
"data": data[:_MAX_FETCH_CHARS],
}
@@ -0,0 +1,102 @@
"""HTML parsing for web_search / web_fetch."""
import html
import re
from html.parser import HTMLParser
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
class SearchResultParser(HTMLParser):
"""DuckDuckGo lite HTML: extract result links and titles."""
def __init__(self) -> None:
super().__init__()
self.results: list[dict[str, str]] = []
self._href: str | None = None
self._title_parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag != "a":
return
href = dict(attrs).get("href")
if not href or "uddg=" not in href:
return
parsed = urlparse(href)
query = parse_qs(parsed.query)
uddg = query.get("uddg", [""])[0]
if not uddg:
return
self._href = unquote(uddg)
self._title_parts = []
def handle_data(self, data: str) -> None:
if self._href is not None:
self._title_parts.append(data)
def handle_endtag(self, tag: str) -> None:
if tag != "a" or self._href is None:
return
title = " ".join("".join(self._title_parts).split())
if title and not any(result["url"] == self._href for result in self.results):
self.results.append({"title": html.unescape(title), "url": self._href})
self._href = None
self._title_parts = []
class HTMLTextParser(HTMLParser):
"""Strip scripts/styles and collect visible text + title for fetch previews."""
def __init__(self) -> None:
super().__init__()
self.title = ""
self.text_parts: list[str] = []
self._in_title = False
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in {"script", "style", "noscript"}:
self._skip_depth += 1
elif tag == "title":
self._in_title = True
def handle_endtag(self, tag: str) -> None:
if tag in {"script", "style", "noscript"} and self._skip_depth:
self._skip_depth -= 1
elif tag == "title":
self._in_title = False
def handle_data(self, data: str) -> None:
text = " ".join(data.split())
if not text:
return
if self._in_title:
self.title = f"{self.title} {text}".strip()
elif not self._skip_depth:
self.text_parts.append(text)
def content_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
parts.append(str(item.get("text", "")))
else:
parts.append(str(getattr(item, "text", "")))
return "\n".join(part for part in parts if part)
return str(content)
def extract_query(text: str) -> str:
match = re.search(r"query:\s*(.+)", text, flags=re.IGNORECASE | re.DOTALL)
if match:
return match.group(1).strip().strip("\"'")
return text.strip()
def extract_url(text: str) -> str:
match = re.search(r"https?://\S+", text)
return match.group(0).rstrip(").,]") if match else text.strip()
@@ -0,0 +1,76 @@
"""Detect forced Anthropic web server tool requests."""
from free_claude_code.core.anthropic import MessagesRequest, Tool
from .parsers import content_text
def forced_tool_turn_text(request: MessagesRequest) -> str:
"""Text for parsing forced server-tool inputs: latest user turn only (avoids stale history)."""
if not request.messages:
return ""
for message in reversed(request.messages):
if message.role == "user":
return content_text(message.content)
return ""
def forced_server_tool_name(request: MessagesRequest) -> str | None:
"""Return web_search or web_fetch only when tool_choice forces that server tool."""
tc = request.tool_choice
if not isinstance(tc, dict):
return None
if tc.get("type") != "tool":
return None
name = tc.get("name")
if name in {"web_search", "web_fetch"}:
return str(name)
return None
def has_tool_named(request: MessagesRequest, name: str) -> bool:
return any(tool.name == name for tool in request.tools or [])
def is_web_server_tool_request(request: MessagesRequest) -> bool:
"""True when the client forces a web server tool via tool_choice (not merely listed)."""
forced = forced_server_tool_name(request)
if forced is None:
return False
return has_tool_named(request, forced)
def is_anthropic_server_tool_definition(tool: Tool) -> bool:
"""Whether ``tool`` refers to an Anthropic server tool (web_search / web_fetch family)."""
name = (tool.name or "").strip()
if name in ("web_search", "web_fetch"):
return True
typ = tool.type
if isinstance(typ, str):
return typ.startswith("web_search") or typ.startswith("web_fetch")
return False
def has_listed_anthropic_server_tools(request: MessagesRequest) -> bool:
"""True when tools include web_search / web_fetch-style entries (listed, forced or not)."""
return any(is_anthropic_server_tool_definition(t) for t in (request.tools or []))
def unsupported_server_tool_error(
request: MessagesRequest, *, web_tools_enabled: bool
) -> str | None:
"""Return the user-facing error when the resolved provider cannot run server tools."""
forced = forced_server_tool_name(request)
if forced and not web_tools_enabled:
return (
f"tool_choice forces Anthropic server tool {forced!r}, but local web server tools are "
"disabled (ENABLE_WEB_SERVER_TOOLS=false). Enable them or remove the forced server tool."
)
if not forced and has_listed_anthropic_server_tools(request):
return (
"FCC cannot pass listed Anthropic server tools (web_search / web_fetch) "
"to OpenAI Chat upstreams. Set ENABLE_WEB_SERVER_TOOLS=true and force the "
"tool with tool_choice, or remove these tools from the request."
)
return None
@@ -0,0 +1,204 @@
"""SSE streaming for local web_search / web_fetch server tool results."""
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
from free_claude_code.core.anthropic import MessagesRequest
from free_claude_code.core.anthropic.server_tool_sse import (
SERVER_TOOL_USE,
WEB_FETCH_TOOL_ERROR,
WEB_FETCH_TOOL_RESULT,
WEB_SEARCH_TOOL_RESULT,
WEB_SEARCH_TOOL_RESULT_ERROR,
)
from free_claude_code.core.anthropic.streaming import format_sse_event
from . import outbound
from .constants import _MAX_FETCH_CHARS
from .egress import WebFetchEgressPolicy
from .parsers import extract_query, extract_url
from .request import (
forced_server_tool_name,
forced_tool_turn_text,
has_tool_named,
)
def _search_summary(query: str, results: list[dict[str, str]]) -> str:
if not results:
return f"No web search results found for: {query}"
lines = [f"Search results for: {query}"]
for index, result in enumerate(results, start=1):
lines.append(f"{index}. {result['title']}\n{result['url']}")
return "\n\n".join(lines)
async def stream_web_server_tool_response(
request: MessagesRequest,
input_tokens: int,
*,
web_fetch_egress: WebFetchEgressPolicy,
verbose_client_errors: bool = False,
) -> AsyncIterator[str]:
"""Stream a minimal Anthropic-shaped turn for forced `web_search` / `web_fetch` (local fallback).
When `ENABLE_WEB_SERVER_TOOLS` is on, this is a proxy-side execution path — not a full
hosted Anthropic citation or encrypted-content pipeline.
"""
tool_name = forced_server_tool_name(request)
if tool_name is None or not has_tool_named(request, tool_name):
return
text = forced_tool_turn_text(request)
message_id = f"msg_{uuid.uuid4()}"
tool_id = f"srvtoolu_{uuid.uuid4().hex}"
usage_key = (
"web_search_requests" if tool_name == "web_search" else "web_fetch_requests"
)
tool_input = (
{"query": extract_query(text)}
if tool_name == "web_search"
else {"url": extract_url(text)}
)
_result_block_for_tool = {
"web_search": WEB_SEARCH_TOOL_RESULT,
"web_fetch": WEB_FETCH_TOOL_RESULT,
}
_error_payload_type_for_tool = {
"web_search": WEB_SEARCH_TOOL_RESULT_ERROR,
"web_fetch": WEB_FETCH_TOOL_ERROR,
}
yield format_sse_event(
"message_start",
{
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"content": [],
"model": request.model,
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": input_tokens, "output_tokens": 1},
},
},
)
yield format_sse_event(
"content_block_start",
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": SERVER_TOOL_USE,
"id": tool_id,
"name": tool_name,
"input": tool_input,
},
},
)
yield format_sse_event(
"content_block_stop", {"type": "content_block_stop", "index": 0}
)
try:
if tool_name == "web_search":
query = str(tool_input["query"])
results = await outbound._run_web_search(query)
result_content: Any = [
{
"type": "web_search_result",
"title": result["title"],
"url": result["url"],
}
for result in results
]
summary = _search_summary(query, results)
result_block_type = WEB_SEARCH_TOOL_RESULT
else:
fetched = await outbound._run_web_fetch(
str(tool_input["url"]), web_fetch_egress
)
result_content = {
"type": "web_fetch_result",
"url": fetched["url"],
"content": {
"type": "document",
"source": {
"type": "text",
"media_type": fetched["media_type"],
"data": fetched["data"],
},
"title": fetched["title"],
"citations": {"enabled": True},
},
"retrieved_at": datetime.now(UTC).isoformat(),
}
summary = fetched["data"][:_MAX_FETCH_CHARS]
result_block_type = WEB_FETCH_TOOL_RESULT
except Exception as error:
fetch_url = str(tool_input["url"]) if tool_name == "web_fetch" else None
outbound._log_web_tool_failure(tool_name, error, fetch_url=fetch_url)
result_block_type = _result_block_for_tool[tool_name]
result_content = {
"type": _error_payload_type_for_tool[tool_name],
"error_code": "unavailable",
}
summary = outbound._web_tool_client_error_summary(
tool_name, error, verbose=verbose_client_errors
)
output_tokens = max(1, len(summary) // 4)
yield format_sse_event(
"content_block_start",
{
"type": "content_block_start",
"index": 1,
"content_block": {
"type": result_block_type,
"tool_use_id": tool_id,
"content": result_content,
},
},
)
yield format_sse_event(
"content_block_stop", {"type": "content_block_stop", "index": 1}
)
# Model-facing summary: stream as normal text deltas (CLI/transcript code reads `text_delta`,
# not eager `text` on `content_block_start`).
yield format_sse_event(
"content_block_start",
{
"type": "content_block_start",
"index": 2,
"content_block": {"type": "text", "text": ""},
},
)
yield format_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": 2,
"delta": {"type": "text_delta", "text": summary},
},
)
yield format_sse_event(
"content_block_stop", {"type": "content_block_stop", "index": 2}
)
yield format_sse_event(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"server_tool_use": {usage_key: 1},
},
},
)
yield format_sse_event("message_stop", {"type": "message_stop"})
@@ -0,0 +1 @@
"""Application use cases, values, and consumer-owned ports."""
@@ -0,0 +1,41 @@
"""Deterministic application and readiness errors."""
from collections.abc import Iterable
from free_claude_code.core.failures import FailureKind
class ApplicationError(Exception):
"""Base for request/readiness failures, not finalized upstream failures."""
kind: FailureKind
status_code: int
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
class InvalidRequestError(ApplicationError):
"""The accepted request cannot be executed deterministically."""
kind = FailureKind.INVALID_REQUEST
status_code = 400
class UnknownProviderError(InvalidRequestError):
"""The configured provider identifier is not registered."""
@classmethod
def for_provider(
cls, provider_id: str, supported_provider_ids: Iterable[str]
) -> UnknownProviderError:
supported = "', '".join(supported_provider_ids)
return cls(f"Unknown provider_type: '{provider_id}'. Supported: '{supported}'")
class ApplicationUnavailableError(ApplicationError):
"""The application cannot currently provide a request runtime."""
kind = FailureKind.UNAVAILABLE
status_code = 503
@@ -0,0 +1,147 @@
"""Provider execution shared by inbound API adapters."""
import sys
from collections.abc import AsyncIterator, Callable
from typing import Literal
from loguru import logger
from free_claude_code.core.anthropic import (
Message,
SystemContent,
Tool,
anthropic_request_snapshot,
get_token_count,
)
from free_claude_code.core.trace import (
close_stream_input,
trace_event,
traced_async_stream,
)
from .ports import ProviderResolver
from .routing import RoutedMessagesRequest
TokenCounter = Callable[
[list[Message], str | list[SystemContent] | None, list[Tool] | None],
int,
]
WireApi = Literal["messages", "responses"]
class ProviderExecutor:
"""Resolve a provider and execute one routed Anthropic Messages stream."""
def __init__(
self,
provider_resolver: ProviderResolver,
*,
token_counter: TokenCounter = get_token_count,
generation_id: int | None = None,
log_raw_payloads: bool = False,
) -> None:
self._provider_resolver = provider_resolver
self._token_counter = token_counter
self._generation_id = generation_id
self._log_raw_payloads = log_raw_payloads
def stream(
self,
routed: RoutedMessagesRequest,
*,
wire_api: WireApi,
raw_log_label: str,
raw_log_payload: object,
request_id: str,
) -> AsyncIterator[str]:
"""Preflight synchronously, then return the traced provider stream."""
provider = self._provider_resolver(routed.resolved.provider_id)
provider.preflight_stream(
routed.request,
thinking_enabled=routed.resolved.thinking_enabled,
)
route_trace: dict[str, object] = {
"stage": "routing",
"event": "free_claude_code.api.route.resolved",
"source": "api",
"request_id": request_id,
"provider_id": routed.resolved.provider_id,
"provider_model": routed.resolved.provider_model,
"provider_model_ref": routed.resolved.provider_model_ref,
"gateway_model": routed.request.model,
"thinking_enabled": routed.resolved.thinking_enabled,
}
if wire_api == "responses":
route_trace["wire_api"] = "responses"
if self._generation_id is not None:
route_trace["generation_id"] = self._generation_id
trace_event(**route_trace)
trace_event(
stage="ingress",
event=(
"free_claude_code.api.responses.request.received"
if wire_api == "responses"
else "free_claude_code.api.request.received"
),
source="api",
message_count=len(routed.request.messages),
snapshot=anthropic_request_snapshot(routed.request),
request_id=request_id,
)
if self._log_raw_payloads:
logger.debug(f"{raw_log_label} [{{}}]: {{}}", request_id, raw_log_payload)
input_tokens = self._token_counter(
routed.request.messages,
routed.request.system,
routed.request.tools,
)
async def provider_body() -> AsyncIterator[str]:
provider_stream: AsyncIterator[str] | None = None
try:
provider_stream = provider.stream_response(
routed.request,
input_tokens=input_tokens,
request_id=request_id,
thinking_enabled=routed.resolved.thinking_enabled,
)
async for chunk in provider_stream:
yield chunk
finally:
if provider_stream is not None:
await close_stream_input(
provider_stream,
owner="provider_executor",
source="api",
preserved_error=sys.exception(),
)
stream_trace: dict[str, object] = {
"request_id": request_id,
"provider_id": routed.resolved.provider_id,
"gateway_model": routed.request.model,
}
if self._generation_id is not None:
stream_trace["generation_id"] = self._generation_id
return traced_async_stream(
provider_body(),
stage="egress",
source="api",
complete_event=(
"free_claude_code.api.responses.stream_completed"
if wire_api == "responses"
else "free_claude_code.api.response.stream_completed"
),
interrupted_event=(
"free_claude_code.api.responses.stream_interrupted"
if wire_api == "responses"
else "free_claude_code.api.response.stream_interrupted"
),
chunk_event=None,
extra=stream_trace,
)
@@ -0,0 +1,11 @@
"""Application-owned model metadata."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ProviderModelInfo:
"""Provider model metadata used to shape the application model catalog."""
model_id: str
supports_thinking: bool | None = None

Some files were not shown because too many files have changed in this diff Show More