chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
3.14.0
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://github.com/astral-sh/uv)
|
||||
[](https://github.com/Alishahryar1/free-claude-code/actions/workflows/tests.yml)
|
||||
[](https://pypi.org/project/ty/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`Alishahryar1/free-claude-code`
|
||||
- 原始仓库:https://github.com/Alishahryar1/free-claude-code
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 130 KiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 133 KiB |
@@ -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"]
|
||||
@@ -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."
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
"""Local-only live smoke tests for free-claude-code."""
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared helpers for local-only smoke tests."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
"""Live prerequisite checks for product smoke."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
"""Product-level end-to-end smoke scenarios."""
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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("-")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Free Claude Code package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP API adapter for Free Claude Code."""
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||