diff --git a/.claude/skills/ai-image-creator/SKILL.md b/.claude/skills/ai-image-creator/SKILL.md index 135d35ad..9a3c642f 100644 --- a/.claude/skills/ai-image-creator/SKILL.md +++ b/.claude/skills/ai-image-creator/SKILL.md @@ -22,6 +22,23 @@ When the user mentions a model keyword in their image request, use the correspon | `flux2` | [FLUX.2 Max](https://openrouter.ai/black-forest-labs/flux.2-max) | "flux2", "flux", "use flux" | | `seedream` | [ByteDance SeedDream 4.5](https://openrouter.ai/bytedance-seed/seedream-4.5) | "seedream", "use seedream" | | `gpt5` | [OpenAI GPT-5 Image](https://openrouter.ai/openai/gpt-5-image) | "gpt5", "gpt5 image", "use gpt5" | +| `nvidia-flux2-klein-4b` | BFL FLUX.2 Klein 4B via NVIDIA NIM (default for `--provider nvidia`) | "nvidia", "flux nvidia", "nvidia flux2" | +| `nvidia-flux-dev` | BFL FLUX.1-dev via NVIDIA NIM (30 steps, high quality) | "nvidia flux dev" | +| `nvidia-flux-schnell` | BFL FLUX.1-schnell via NVIDIA NIM (4 steps, fastest) | "nvidia schnell", "flux rápido" | +| `image2` | OpenAI GPT Image 2 via Images API direta (default for `--provider openai`) | "image2", "gpt image 2", "imagem openai" | + +**OpenAI Images API notes (`image2`):** +- Requires a **platform API key** (`AI_IMG_CREATOR_OPENAI_KEY` or `OPENAI_API_KEY`, billing on platform.openai.com). +- **ChatGPT Plus / Codex OAuth tokens CANNOT generate images** — they lack the `api.model.images.request` scope (verified: API returns 401). Do not suggest the Codex login as an image path. +- `-a` maps to the supported sizes (1024x1024, 1536x1024, 1024x1536). `--image-size`, `-r` (reference images) and `--analyze` are not supported on this provider. +- Excellent text rendering (including Portuguese accents) — good choice for text-critical art. + +**NVIDIA NIM notes:** +- Free tier with generous credits — good fallback when OpenRouter hits rate limits. +- Generation is fast (~2-5s). Output is JPEG, auto-converted to PNG via ImageMagick/ffmpeg. +- Dimensions are restricted (768-1344 per side, ≤1.06MP total) — `-a` maps to the closest supported size. `--image-size` is not supported. +- Text rendering (especially Portuguese accents like Ç/Ã) is hit-or-miss on all FLUX variants — for text-critical art, generate 2-3 candidates and pick, or use `gemini`/`gpt5` which render text reliably. +- Reference images (`-r`) and `--analyze` are not supported on the NVIDIA provider. ## Instructions @@ -99,6 +116,7 @@ The script auto-loads env vars from the workspace `.env`. Choose provider based - If `AI_IMG_CREATOR_CF_ACCOUNT_ID` + `AI_IMG_CREATOR_CF_GATEWAY_ID` + `AI_IMG_CREATOR_CF_TOKEN` are set → use default (gateway mode, no flag needed) - If only `AI_IMG_CREATOR_OPENROUTER_KEY` is set → use default (`--provider openrouter`, implicit) - If only `AI_IMG_CREATOR_GEMINI_KEY` is set → use `--provider google` +- If `NVIDIA_API_KEY` is set → `--provider nvidia` is available (FLUX models, free tier). Auto-selected when the model keyword starts with `nvidia-` - **Do NOT use `source .env`** — the Python script loads it internally. Just run the command directly. ### Step 2: Run Generation Script @@ -193,6 +211,7 @@ If the user needs resizing, format conversion, or other manipulation, first dete | `AI_IMG_CREATOR_CF_TOKEN` | Gateway mode | Gateway auth token | | `AI_IMG_CREATOR_OPENROUTER_KEY` | Direct OpenRouter | OpenRouter API key (`sk-or-...`) | | `AI_IMG_CREATOR_GEMINI_KEY` | Direct Google | Google AI Studio API key | +| `NVIDIA_API_KEY` | NVIDIA NIM | NVIDIA API key (`nvapi-...`) — same key used by the dashboard NVIDIA provider | Gateway mode activates when all 3 `CF_*` vars are set. Falls back to direct mode if gateway fails. diff --git a/.claude/skills/ai-image-creator/scripts/generate-image.py b/.claude/skills/ai-image-creator/scripts/generate-image.py index 71b040a2..3de668f9 100644 --- a/.claude/skills/ai-image-creator/scripts/generate-image.py +++ b/.claude/skills/ai-image-creator/scripts/generate-image.py @@ -1,19 +1,22 @@ #!/usr/bin/env python3 -"""AI Image Generator — Generate PNG images via multiple OpenRouter models or Google AI Studio. +"""AI Image Generator — Generate PNG images via multiple providers. Supports multiple image generation models via keyword shortcuts: - gemini — Google Gemini 3.1 Flash (default, multimodal) - riverflow — Sourceful Riverflow v2 Fast (image-only) - flux2 — Black Forest Labs FLUX.2 Klein 4B (image-only) - seedream — ByteDance SeedDream 4.5 (image-only) - gpt5 — OpenAI GPT-5 Image Mini (multimodal) - -Routes through Cloudflare AI Gateway BYOK when configured, with automatic -fallback to direct API calls. Uses only Python stdlib (no pip dependencies). + gemini — Google Gemini 3.1 Flash (multimodal, OpenRouter) + riverflow — Sourceful Riverflow v2 Fast (image-only, OpenRouter) + flux2 — Black Forest Labs FLUX.2 Klein 4B (image-only, OpenRouter) + seedream — ByteDance SeedDream 4.5 (image-only, OpenRouter) + gpt5 — OpenAI GPT-5 Image Mini (multimodal, OpenRouter) + nvidia-flux2-klein-4b — BFL FLUX.2 Klein 4B via NVIDIA NIM (default nvidia, best text rendering) + nvidia-flux-dev — BFL FLUX.1-dev via NVIDIA NIM (high quality) + nvidia-flux-schnell — BFL FLUX.1-schnell via NVIDIA NIM (fastest) + +Routes through Cloudflare AI Gateway BYOK when configured (OpenRouter/Google only), +with automatic fallback to direct API calls. Uses only Python stdlib (no pip dependencies). Usage: uv run python generate-image.py --output path.png --prompt "description" - uv run python generate-image.py --output path.png --model riverflow --prompt "description" + uv run python generate-image.py --output path.png --model nvidia-flux2-klein-4b --prompt "description" uv run python generate-image.py --output path.png --prompt-file prompt.txt uv run python generate-image.py --list-models """ @@ -40,6 +43,39 @@ DEFAULT_MODELS = { "openrouter": "google/gemini-3.1-flash-image-preview", "google": "gemini-3.1-flash-image-preview", + "nvidia": "black-forest-labs/flux.2-klein-4b", + "openai": "gpt-image-2", +} + +# Aspect ratio → size string accepted by the OpenAI Images API +# (gpt-image models accept 1024x1024, 1536x1024, 1024x1536, auto). +OPENAI_SIZE_MAP = { + "1:1": "1024x1024", + "16:9": "1536x1024", + "3:2": "1536x1024", + "4:3": "1536x1024", + "5:4": "1536x1024", + "21:9": "1536x1024", + "9:16": "1024x1536", + "2:3": "1024x1536", + "3:4": "1024x1536", + "4:5": "1024x1536", +} + +# Aspect ratio → (width, height) within the dimension set NVIDIA NIM accepts +# (768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344) AND the total +# pixel budget (width × height <= 1,062,400 — validated by the API). +NVIDIA_ASPECT_MAP = { + "1:1": (1024, 1024), + "16:9": (1344, 768), + "9:16": (768, 1344), + "3:2": (1152, 768), + "2:3": (768, 1152), + "4:3": (1024, 768), + "3:4": (768, 1024), + "5:4": (1024, 832), + "4:5": (832, 1024), + "21:9": (1344, 768), # closest supported — true 21:9 unavailable } # Model registry — maps keyword shortcuts to model metadata. @@ -71,6 +107,58 @@ "modalities": ["image", "text"], "description": "OpenAI GPT-5 Image — multimodal (text+image)", }, + # OpenAI Images API — requires a PLATFORM API key (api.openai.com billing). + # ChatGPT Plus / Codex OAuth tokens lack the api.model.images.request + # scope and cannot generate images (verified empirically — 401). + "image2": { + "id": "gpt-image-2", + "provider": "openai", + "modalities": ["image"], + "description": "OpenAI GPT Image 2 — Images API direta (requer API key de plataforma)", + }, + # NVIDIA NIM Flux models + "nvidia-flux-dev": { + "id": "black-forest-labs/flux.1-dev", + "provider": "nvidia", + "modalities": ["image"], + "description": "Black Forest Labs FLUX.1-dev — high quality, NVIDIA NIM", + "nvidia_params": { + "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "cfg_scale": {"type": "number", "default": 5, "minimum": 1, "maximum": 9, "description": "How strictly the diffusion process adheres to the prompt text"}, + "steps": {"type": "integer", "default": 30, "minimum": 1, "maximum": 50, "description": "Number of diffusion steps"}, + "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296, "description": "Random seed (0 for random)"}, + "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1}, + }, + }, + "nvidia-flux-schnell": { + "id": "black-forest-labs/flux.1-schnell", + "provider": "nvidia", + "modalities": ["image"], + "description": "Black Forest Labs FLUX.1-schnell — fast, NVIDIA NIM", + "nvidia_params": { + "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "cfg_scale": {"type": "number", "default": 0, "minimum": 0, "maximum": 0}, + "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 30}, + "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296}, + "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1}, + }, + }, + "nvidia-flux2-klein-4b": { + "id": "black-forest-labs/flux.2-klein-4b", + "provider": "nvidia", + "modalities": ["image"], + "description": "Black Forest Labs FLUX.2 Klein 4B — fastest, NVIDIA NIM", + "nvidia_params": { + "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "cfg_scale": {"type": "number", "default": 1, "minimum": 1, "maximum": 9}, + "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 4}, + "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296}, + "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1}, + }, + }, } # Environment variable names (prefixed to avoid collisions) @@ -79,6 +167,8 @@ ENV_CF_TOKEN = "AI_IMG_CREATOR_CF_TOKEN" ENV_OPENROUTER_KEY = "AI_IMG_CREATOR_OPENROUTER_KEY" ENV_GEMINI_KEY = "AI_IMG_CREATOR_GEMINI_KEY" +ENV_NVIDIA_KEY = "NVIDIA_API_KEY" +ENV_OPENAI_KEY = "AI_IMG_CREATOR_OPENAI_KEY" # falls back to OPENAI_API_KEY def _load_dotenv() -> None: """Load .env files into os.environ (stdlib only, no pip deps). @@ -156,18 +246,18 @@ def resolve_model(model_arg: str | None, provider: str) -> tuple[str, list[str]] """Resolve a model keyword or full ID to (model_id, modalities). Supports three modes: - 1. No --model flag: returns the default model for the provider (gemini). + 1. No --model flag: returns the default model for the provider. 2. Keyword match (e.g. 'riverflow'): looks up MODEL_REGISTRY. 3. Full model ID (e.g. 'sourceful/riverflow-v2-pro'): reverse-lookups registry for modalities, or defaults to ["image", "text"] if unknown. Args: model_arg: The --model CLI value (keyword, full model ID, or None). - provider: Either 'openrouter' or 'google'. + provider: 'openrouter', 'google', or 'nvidia'. Returns: Tuple of (model_id, modalities_list) where model_id is the full - OpenRouter model identifier and modalities_list is the correct + model identifier and modalities_list is the correct modalities array for the API request. """ if model_arg is None: @@ -175,6 +265,8 @@ def resolve_model(model_arg: str | None, provider: str) -> tuple[str, list[str]] if provider == "openrouter": entry = MODEL_REGISTRY.get("gemini", {}) return model_id, entry.get("modalities", ["image", "text"]) + if provider in ("nvidia", "openai"): + return model_id, ["image"] return model_id, ["image", "text"] # Check keyword match (case-insensitive) @@ -203,7 +295,7 @@ def parse_args() -> argparse.Namespace: image_size, model, list_models, debug, and verbose attributes. """ parser = argparse.ArgumentParser( - description="Generate PNG images using AI (multiple models via OpenRouter/Google AI Studio)" + description="Generate PNG images using AI (OpenRouter, Google AI Studio, or NVIDIA NIM)" ) parser.add_argument( "-o", "--output", required=False, default=None, help="Output PNG file path (required unless --list-models)" @@ -218,9 +310,9 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--provider", - choices=["openrouter", "google"], + choices=["openrouter", "google", "nvidia", "openai"], default="openrouter", - help="API provider (default: openrouter)", + help="API provider (default: openrouter). Use 'nvidia' for NVIDIA NIM Flux models, 'openai' for GPT Image via Images API.", ) parser.add_argument( "-a", "--aspect-ratio", @@ -235,7 +327,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "-m", "--model", default=None, - help="Model keyword (gemini, riverflow, flux2, seedream, gpt5) or full model ID", + help="Model keyword (gemini, riverflow, flux2, seedream, gpt5, nvidia-flux2-klein-4b, nvidia-flux-dev, nvidia-flux-schnell) or full model ID", ) parser.add_argument( "-r", "--ref", @@ -264,6 +356,37 @@ def parse_args() -> argparse.Namespace: action="store_true", help="List available model keywords and exit", ) + # NVIDIA NIM specific parameters + parser.add_argument( + "--width", + type=int, + default=None, + help="Image width for NVIDIA Flux models (768-1344, default: 1024)", + ) + parser.add_argument( + "--height", + type=int, + default=None, + help="Image height for NVIDIA Flux models (768-1344, default: 1024)", + ) + parser.add_argument( + "--cfg-scale", + type=float, + default=None, + help="Classifier-free guidance scale (default: 5 for flux-dev, 0 for others)", + ) + parser.add_argument( + "--steps", + type=int, + default=None, + help="Number of diffusion steps (default: 30 for flux-dev, 4 for schnell/flux2)", + ) + parser.add_argument( + "--seed", + type=int, + default=None, + help="Random seed (0 for random, default: 0)", + ) parser.add_argument( "--debug", action="store_true", @@ -364,9 +487,31 @@ def detect_mode(provider: str) -> tuple[str, dict[str, str]]: if provider == "openrouter": direct_key = os.environ.get(ENV_OPENROUTER_KEY, "").strip() log.debug(f"Env check: {ENV_OPENROUTER_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") - else: + elif provider == "google": direct_key = os.environ.get(ENV_GEMINI_KEY, "").strip() log.debug(f"Env check: {ENV_GEMINI_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") + elif provider == "nvidia": + direct_key = os.environ.get(ENV_NVIDIA_KEY, "").strip() + log.debug(f"Env check: {ENV_NVIDIA_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") + elif provider == "openai": + direct_key = ( + os.environ.get(ENV_OPENAI_KEY, "").strip() + or os.environ.get("OPENAI_API_KEY", "").strip() + ) + log.debug(f"Env check: {ENV_OPENAI_KEY}/OPENAI_API_KEY={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") + else: + log.debug(f"Unknown provider: {provider}") + direct_key = "" + + # NVIDIA NIM and OpenAI Images always use direct mode (no gateway) + if provider in ("nvidia", "openai") and direct_key: + log.info(f"Mode: direct ({provider} always uses direct API)") + return "direct", {"direct_key": direct_key} + + # The gateway path is OpenRouter/Google-shaped — never route openai + # (Images API) through it. + if provider == "openai": + has_gateway = False if has_gateway: log.info(f"Mode: gateway (account={cf_account}, gateway={cf_gateway})") @@ -391,9 +536,16 @@ def detect_mode(provider: str) -> tuple[str, dict[str, str]]: if provider == "openrouter": print("For direct OpenRouter access, set:", file=sys.stderr) print(f" export {ENV_OPENROUTER_KEY}=sk-or-...", file=sys.stderr) - else: + elif provider == "google": print("For direct Google AI Studio access, set:", file=sys.stderr) print(f" export {ENV_GEMINI_KEY}=AI...", file=sys.stderr) + elif provider == "nvidia": + print("For NVIDIA NIM access, set:", file=sys.stderr) + print(f" export {ENV_NVIDIA_KEY}=your-nvidia-api-key", file=sys.stderr) + elif provider == "openai": + print("For OpenAI Images API access, set a PLATFORM key (billing on", file=sys.stderr) + print("platform.openai.com — ChatGPT Plus/Codex OAuth cannot generate images):", file=sys.stderr) + print(f" export {ENV_OPENAI_KEY}=sk-... (or OPENAI_API_KEY)", file=sys.stderr) print("", file=sys.stderr) print( "See references/setup-guide.md for full setup instructions.", @@ -434,8 +586,14 @@ def build_direct_url(provider: str, model: str) -> str: """ if provider == "openrouter": url = "https://openrouter.ai/api/v1/chat/completions" - else: + elif provider == "google": url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + elif provider == "nvidia": + url = build_nvidia_url(model) + elif provider == "openai": + url = "https://api.openai.com/v1/images/generations" + else: + raise RuntimeError(f"Unknown provider for URL: {provider}") log.debug(f"Built direct URL: {url}") return url @@ -456,7 +614,13 @@ def build_headers(provider: str, mode: str, config: dict[str, str]) -> dict[str, "User-Agent": "ai-image-creator/1.0", } - if mode == "gateway": + if provider == "nvidia": + headers = build_nvidia_headers(config["direct_key"]) + # Skip all other header logic for NVIDIA + safe_headers = {k: (f"{v[:12]}...{mask_key(v)}" if k.lower() in ("authorization", "cf-aig-authorization", "x-goog-api-key") else v) for k, v in headers.items()} + log.debug(f"Request headers: {json.dumps(safe_headers, indent=2)}") + return headers + elif mode == "gateway": headers["cf-aig-authorization"] = f"Bearer {config['cf_token']}" if provider == "google": headers["cf-aig-byok-alias"] = "aistudio" @@ -465,8 +629,10 @@ def build_headers(provider: str, mode: str, config: dict[str, str]) -> dict[str, else: if provider == "openrouter": headers["Authorization"] = f"Bearer {config['direct_key']}" - else: + elif provider == "google": headers["x-goog-api-key"] = config["direct_key"] + elif provider == "openai": + headers["Authorization"] = f"Bearer {config['direct_key']}" # Log headers with masked sensitive values safe_headers = {} @@ -539,6 +705,15 @@ def build_request_body( if image_config: body["image_config"] = image_config log.debug(f"Image config: {json.dumps(image_config)}") + elif provider == "openai": + # OpenAI Images API — flat prompt, size from the aspect-ratio map. + body = {"model": model, "prompt": prompt} + if aspect_ratio: + size = OPENAI_SIZE_MAP.get(aspect_ratio) + if size: + body["size"] = size + else: + log.warning(f"Aspect ratio {aspect_ratio} not mapped for OpenAI; using model default") else: # Google AI Studio parts: list[dict[str, Any]] = [{"text": prompt}] @@ -733,6 +908,56 @@ def extract_image_google(response: dict) -> tuple[bytes, str]: return image_bytes, text_content +def extract_image_nvidia(response: dict) -> tuple[bytes, str]: + """Extract base64 image data from NVIDIA NIM response. + + Args: + response: Parsed JSON response from NVIDIA NIM API. + + Returns: + Tuple of (image_bytes, text_content) where image_bytes is the decoded + PNG data and text_content is empty (NVIDIA returns only images). + + Raises: + RuntimeError: If no artifacts found in response. + """ + artifacts = response.get("artifacts", []) + if not artifacts: + raise RuntimeError(f"No artifacts in NVIDIA response: {json.dumps(response)[:500]}") + b64_data = artifacts[0]["base64"] + image_bytes = base64.b64decode(b64_data) + log.info(f"Decoded image: {len(image_bytes)} bytes ({len(b64_data)} base64 chars)") + return image_bytes, "" + + +def extract_image_openai(response: dict) -> tuple[bytes, str]: + """Extract base64 image data from an OpenAI Images API response. + + Args: + response: Parsed JSON response from api.openai.com/v1/images/generations. + + Returns: + Tuple of (image_bytes, text_content) where text_content is empty + (the Images API returns only images). + + Raises: + RuntimeError: If the response carries an error or no image data. + """ + error = response.get("error") + if error: + msg = error.get("message", str(error)) if isinstance(error, dict) else str(error) + raise RuntimeError(f"OpenAI API error: {msg}") + data = response.get("data", []) + if not data: + raise RuntimeError(f"No data in OpenAI response: {json.dumps(response)[:500]}") + b64_data = data[0].get("b64_json", "") + if not b64_data: + raise RuntimeError(f"No b64_json in OpenAI response item: {json.dumps(data[0])[:300]}") + image_bytes = base64.b64decode(b64_data) + log.info(f"Decoded image: {len(image_bytes)} bytes ({len(b64_data)} base64 chars)") + return image_bytes, "" + + def extract_text_openrouter(response: dict) -> str: """Extract text-only content from OpenRouter response (analyze mode). @@ -795,6 +1020,79 @@ def extract_text_google(response: dict) -> str: return text_content +def build_nvidia_url(model_id: str) -> str: + """Build the NVIDIA NIM image generation endpoint URL. + + The NVIDIA AI Foundation endpoint format is: + https://ai.api.nvidia.com/v1/genai/{model_id} + + Note: The model_id uses '.' not '-' in the version suffix + (e.g. 'black-forest-labs/flux.2-klein-4b'). + + Args: + model_id: Full model ID (e.g. 'black-forest-labs/flux.2-klein-4b'). + + Returns: + Full URL for the NVIDIA AI Foundation endpoint. + """ + # Registry IDs already use the canonical dotted form (flux.1-dev, + # flux.2-klein-4b) — pass through verbatim. Rewriting suffixes here + # breaks valid IDs (flux.1-dev would become flux.1.dev → 404). + return f"https://ai.api.nvidia.com/v1/genai/{model_id}" + + +def build_nvidia_headers(api_key: str) -> dict[str, str]: + """Build HTTP headers for NVIDIA NIM API. + + Args: + api_key: NVIDIA API key. + + Returns: + Dict of HTTP header name-value pairs. + """ + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + } + + +def build_nvidia_request_body( + model_id: str, + prompt: str, + width: int, + height: int, + cfg_scale: float = 1, + steps: int = 4, + seed: int = 0, + samples: int = 1, +) -> dict[str, Any]: + """Build JSON request body for NVIDIA NIM Flux models. + + Args: + model_id: Full model ID. + prompt: The image generation prompt text. + width: Image width (768-1344). + height: Image height (768-1344). + cfg_scale: Classifier-free guidance scale (default: 1, min: 1). + steps: Number of diffusion steps. + seed: Random seed (0 for random). + samples: Number of images to generate (always 1). + + Returns: + Dict suitable for JSON serialization as request body. + """ + return { + "prompt": prompt, + "width": width, + "height": height, + "cfg_scale": cfg_scale, + "steps": steps, + "seed": seed, + "samples": samples, + } + + def find_imagemagick() -> str | None: """Find ImageMagick binary (magick for v7, convert for v6). @@ -948,6 +1246,15 @@ def log_cost_entry( "completion_tokens": usage.get("completion_tokens", 0), "total_tokens": usage.get("total_tokens", 0), } + elif provider == "openai": + # Images API usage format: input_tokens / output_tokens / total_tokens + usage = response.get("usage", {}) + if usage: + token_usage = { + "prompt_tokens": usage.get("input_tokens", 0), + "completion_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + } else: # Google AI Studio format usage = response.get("usageMetadata", {}) @@ -1110,11 +1417,24 @@ def main() -> None: file=sys.stderr, ) + # Auto-detect provider from the model keyword / id (registry entries with + # an explicit "provider" field, e.g. nvidia-flux2-klein-4b -> nvidia, + # image2 -> openai). + provider = args.provider + if args.model: + _kw = args.model.lower().strip() + _entry = MODEL_REGISTRY.get(_kw) or next( + (e for e in MODEL_REGISTRY.values() if e.get("id") == args.model), None + ) + if _entry and _entry.get("provider"): + provider = _entry["provider"] + log.debug(f"Auto-detected provider '{provider}' from model '{args.model}'") + # Resolve model and modalities - model, modalities = resolve_model(args.model, args.provider) + model, modalities = resolve_model(args.model, provider) # Google direct API needs model ID without the OpenRouter "google/" prefix - if args.provider == "google" and model.startswith("google/"): + if provider == "google" and model.startswith("google/"): model = model[len("google/"):] log.debug(f"Stripped google/ prefix for direct API: {model}") @@ -1177,7 +1497,7 @@ def main() -> None: modalities = ["text"] print("Mode: analyze (text-only output)", file=sys.stderr) - print(f"Provider: {args.provider}", file=sys.stderr) + print(f"Provider: {provider}", file=sys.stderr) print(f"Model: {model}", file=sys.stderr) print(f"Modalities: {', '.join(modalities)}", file=sys.stderr) print(f"Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}", file=sys.stderr) @@ -1187,21 +1507,58 @@ def main() -> None: print(f"Image size: {args.image_size}", file=sys.stderr) # Detect mode - mode, config = detect_mode(args.provider) + mode, config = detect_mode(provider) print(f"Mode: {mode}", file=sys.stderr) # Build request if mode == "gateway": - url = build_gateway_url(args.provider, model, config) + url = build_gateway_url(provider, model, config) else: - url = build_direct_url(args.provider, model) - - headers = build_headers(args.provider, mode, config) - body = build_request_body( - args.provider, model, prompt, args.aspect_ratio, args.image_size, - modalities=modalities, - ref_images=ref_images if ref_images else None, - ) + url = build_direct_url(provider, model) + + headers = build_headers(provider, mode, config) + + # NVIDIA uses a different request body format (flat prompt, not contents/parts) + if provider == "nvidia": + # Per-model defaults from the registry — flux.1-dev needs ~30 steps / + # cfg 5, while schnell and flux.2-klein are distilled 4-step models. + registry_entry = next( + (e for e in MODEL_REGISTRY.values() + if e.get("id") == model and e.get("provider") == "nvidia"), + {}, + ) + nv_defaults = { + k: v.get("default") + for k, v in (registry_entry.get("nvidia_params") or {}).items() + if isinstance(v, dict) and "default" in v + } + width = args.width or nv_defaults.get("width") or 1024 + height = args.height or nv_defaults.get("height") or 1024 + # -a/--aspect-ratio maps to the closest dimensions NVIDIA accepts + if args.aspect_ratio and not (args.width or args.height): + dims = NVIDIA_ASPECT_MAP.get(args.aspect_ratio) + if dims: + width, height = dims + else: + print( + f"WARNING: aspect ratio {args.aspect_ratio} not mapped for NVIDIA; " + f"using {width}x{height}", + file=sys.stderr, + ) + body = build_nvidia_request_body( + model, prompt, + width=width, + height=height, + cfg_scale=args.cfg_scale if args.cfg_scale is not None else nv_defaults.get("cfg_scale", 1), + steps=args.steps if args.steps is not None else nv_defaults.get("steps", 4), + seed=args.seed if args.seed is not None else 0, + ) + else: + body = build_request_body( + provider, model, prompt, args.aspect_ratio, args.image_size, + modalities=modalities, + ref_images=ref_images if ref_images else None, + ) print(f"URL: {url}", file=sys.stderr) if args.analyze: @@ -1221,8 +1578,8 @@ def main() -> None: file=sys.stderr, ) log.info("Initiating fallback to direct API") - url = build_direct_url(args.provider, model) - headers = build_headers(args.provider, "direct", config) + url = build_direct_url(provider, model) + headers = build_headers(provider, "direct", config) try: response = make_request(url, headers, body) except RuntimeError as e2: @@ -1238,8 +1595,10 @@ def main() -> None: if args.analyze: total_elapsed = time.time() - total_start try: - if args.provider == "openrouter": + if provider == "openrouter": analysis_text = extract_text_openrouter(response) + elif provider in ("nvidia", "openai"): + raise RuntimeError(f"Analyze mode not supported for {provider} provider") else: analysis_text = extract_text_google(response) except RuntimeError as e: @@ -1254,7 +1613,7 @@ def main() -> None: try: log_cost_entry( response=response, - provider=args.provider, + provider=provider, model=model, mode=mode, aspect_ratio=None, @@ -1271,7 +1630,7 @@ def main() -> None: "ok": True, "analyze": True, "analysis": analysis_text, - "provider": args.provider, + "provider": provider, "model": model, "mode": mode, "elapsed_seconds": round(total_elapsed, 1), @@ -1285,8 +1644,12 @@ def main() -> None: # Extract image try: - if args.provider == "openrouter": + if provider == "openrouter": image_bytes, text_content = extract_image_openrouter(response) + elif provider == "nvidia": + image_bytes, text_content = extract_image_nvidia(response) + elif provider == "openai": + image_bytes, text_content = extract_image_openai(response) else: image_bytes, text_content = extract_image_google(response) except RuntimeError as e: @@ -1294,6 +1657,49 @@ def main() -> None: log.debug(f"Image extraction failed. Raw response keys: {list(response.keys()) if response else 'None'}") sys.exit(1) + # NVIDIA NIM returns JPEG bytes — convert when the user asked for .png + if ( + provider == "nvidia" + and output_path is not None + and output_path.suffix.lower() == ".png" + and image_bytes[:2] == b"\xff\xd8" + ): + magick_cmd = find_imagemagick() + ffmpeg_cmd = shutil.which("ffmpeg") + if magick_cmd or ffmpeg_cmd: + # Temp files live next to the output — snap-confined ffmpeg + # cannot read the system /tmp directory. + output_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, dir=output_path.parent) as tj: + jpg_tmp = Path(tj.name) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False, dir=output_path.parent) as tp: + png_tmp = Path(tp.name) + try: + jpg_tmp.write_bytes(image_bytes) + if magick_cmd: + cmd = [magick_cmd, str(jpg_tmp), str(png_tmp)] + else: + cmd = ["ffmpeg", "-y", "-i", str(jpg_tmp), str(png_tmp)] + conv = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if conv.returncode == 0 and png_tmp.stat().st_size > 0: + image_bytes = png_tmp.read_bytes() + log.info("Converted NVIDIA JPEG output to PNG") + else: + print( + "WARNING: JPEG->PNG conversion failed - saving original JPEG " + "bytes under the .png name", + file=sys.stderr, + ) + finally: + jpg_tmp.unlink(missing_ok=True) + png_tmp.unlink(missing_ok=True) + else: + print( + "WARNING: NVIDIA returned JPEG and no converter (imagemagick/ffmpeg) " + "is installed - saving JPEG bytes under the .png name", + file=sys.stderr, + ) + # Write output (or process transparent mode) assert output_path is not None # guaranteed by validation above output_path.parent.mkdir(parents=True, exist_ok=True) @@ -1319,7 +1725,7 @@ def main() -> None: try: prompt_meta = f"# Prompt\n\n" prompt_meta += f"- **Model:** {model}\n" - prompt_meta += f"- **Provider:** {args.provider} ({mode})\n" + prompt_meta += f"- **Provider:** {provider} ({mode})\n" if args.aspect_ratio: prompt_meta += f"- **Aspect ratio:** {args.aspect_ratio}\n" if args.image_size: @@ -1350,7 +1756,7 @@ def main() -> None: try: log_cost_entry( response=response, - provider=args.provider, + provider=provider, model=model, mode=mode, aspect_ratio=args.aspect_ratio, @@ -1367,7 +1773,7 @@ def main() -> None: "ok": True, "output": str(output_path), "size_bytes": len(image_bytes), - "provider": args.provider, + "provider": provider, "model": model, "mode": mode, "elapsed_seconds": round(total_elapsed, 1), diff --git a/Makefile b/Makefile index bbcec2ed..bbb3cf08 100644 --- a/Makefile +++ b/Makefile @@ -151,6 +151,8 @@ bling-auth: ## 🔐 Bling OAuth2 login (one-time: capture access + refre @python3 .claude/skills/int-bling/scripts/bling_auth.py telegram: ## 📨 Start Telegram bot in background (screen) + @command -v screen >/dev/null 2>&1 || { echo "❌ 'screen' is not installed — run: sudo apt install screen"; exit 1; } + @command -v bun >/dev/null 2>&1 || [ -x "$$HOME/.bun/bin/bun" ] || { echo "❌ 'bun' is not installed (required by the telegram plugin MCP) — run: curl -fsSL https://bun.sh/install | bash"; exit 1; } @if screen -list | grep -q '\.telegram'; then \ echo "⚠ Telegram bot is already running. Use 'make telegram-stop' first or 'make telegram-attach' to connect."; \ else \ diff --git a/config/providers.example.json b/config/providers.example.json index 17a4d61f..35d5cb19 100644 --- a/config/providers.example.json +++ b/config/providers.example.json @@ -20,6 +20,22 @@ }, "default_base_url": "https://openrouter.ai/api/v1", "default_model": "anthropic/claude-sonnet-4", + "requires_logout": true, + "mode": "code" + }, + "nvidia": { + "name": "NVIDIA NIM", + "description": "Modelos hospedados na NVIDIA (DeepSeek, Llama, Qwen, etc.) via API OpenAI-compatível", + "cli_command": "openclaude", + "mode": "code", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + "OPENAI_API_KEY": "", + "OPENAI_MODEL": "" + }, + "default_base_url": "https://integrate.api.nvidia.com/v1", + "default_model": "deepseek-ai/deepseek-v3.1", "requires_logout": true }, "omnirouter": { @@ -34,7 +50,8 @@ }, "default_base_url": "", "default_model": "", - "requires_logout": true + "requires_logout": true, + "mode": "code" }, "openai": { "name": "OpenAI (API Key)", diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 2dccb597..b8f4fe81 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -132,6 +132,7 @@ def _cors_allowed_origins(): goal_id TEXT, required_secrets TEXT DEFAULT '[]', decision_prompt TEXT NOT NULL, + handler TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -516,6 +517,10 @@ def _cors_allowed_origins(): # source_plugin: tag heartbeats that were contributed by a plugin so the # plugin detail page can filter them via GET /api/heartbeats?source_plugin=. _hb_cols = {row[1] for row in _cur.execute("PRAGMA table_info(heartbeats)").fetchall()} + if "handler" not in _hb_cols: + _cur.execute("ALTER TABLE heartbeats ADD COLUMN handler TEXT") + _conn.commit() + _hb_cols.add("handler") if "source_plugin" not in _hb_cols: _cur.execute("ALTER TABLE heartbeats ADD COLUMN source_plugin TEXT") _conn.commit() diff --git a/dashboard/backend/heartbeat_dispatcher.py b/dashboard/backend/heartbeat_dispatcher.py index a19f9160..ca3970b9 100644 --- a/dashboard/backend/heartbeat_dispatcher.py +++ b/dashboard/backend/heartbeat_dispatcher.py @@ -189,15 +189,15 @@ def _sync_heartbeats_to_db(): """INSERT INTO heartbeats (id, agent, interval_seconds, max_turns, timeout_seconds, lock_timeout_seconds, wake_triggers, enabled, goal_id, - required_secrets, decision_prompt, source_plugin, + required_secrets, decision_prompt, handler, source_plugin, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( hb.id, hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), int(hb.enabled), hb.goal_id, json.dumps(hb.required_secrets), hb.decision_prompt, - hb.source_plugin, + hb.handler, hb.source_plugin, now, now, ), ) @@ -207,14 +207,14 @@ def _sync_heartbeats_to_db(): """UPDATE heartbeats SET agent=?, interval_seconds=?, max_turns=?, timeout_seconds=?, lock_timeout_seconds=?, wake_triggers=?, goal_id=?, - required_secrets=?, decision_prompt=?, source_plugin=?, + required_secrets=?, decision_prompt=?, handler=?, source_plugin=?, updated_at=? WHERE id=?""", ( hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), hb.goal_id, json.dumps(hb.required_secrets), hb.decision_prompt, - hb.source_plugin, now, + hb.handler, hb.source_plugin, now, hb.id, ), ) diff --git a/dashboard/backend/heartbeat_runner.py b/dashboard/backend/heartbeat_runner.py index 9966643b..c02d2fc8 100644 --- a/dashboard/backend/heartbeat_runner.py +++ b/dashboard/backend/heartbeat_runner.py @@ -80,13 +80,13 @@ def _upsert_heartbeat_from_yaml(heartbeat_id: str) -> dict | None: """INSERT OR REPLACE INTO heartbeats (id, agent, interval_seconds, max_turns, timeout_seconds, lock_timeout_seconds, wake_triggers, enabled, goal_id, - required_secrets, decision_prompt, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + required_secrets, decision_prompt, handler, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( hb.id, hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), int(hb.enabled), hb.goal_id, - json.dumps(hb.required_secrets), hb.decision_prompt, + json.dumps(hb.required_secrets), hb.decision_prompt, hb.handler, now, now, ), ) @@ -220,7 +220,63 @@ def step7_invoke_claude( max_turns: int, timeout_seconds: int, ) -> dict: - """Invoke Claude via subprocess with hard timeout. Returns result dict.""" + """Invoke the agent CLI with automatic provider fallback. + + Routes through provider_fallback.invoke_with_fallback so a 429/quota error + on the active provider rotates to the next model/provider in the chain + (ending at native `claude`) instead of failing the whole heartbeat run. + + Disable with HEARTBEAT_PROVIDER_FALLBACK=0 (or if provider_fallback is + unavailable) → falls back to a direct native `claude` call. The returned + dict keeps the same contract step8_persist expects. + """ + use_fallback = os.environ.get("HEARTBEAT_PROVIDER_FALLBACK", "1").lower() not in ( + "0", "false", "no", + ) + if use_fallback: + try: + from provider_fallback import invoke_with_fallback + + result = invoke_with_fallback( + prompt=prompt, + max_turns=max_turns, + timeout_seconds=timeout_seconds, + agent=agent, + ) + # Preserve the step7 contract; provider_fallback already returns + # status/output/error/duration_ms/tokens_*/cost_usd and adds + # provider_id/model/attempt metadata for observability. + result.setdefault("tokens_in", None) + result.setdefault("tokens_out", None) + result.setdefault("cost_usd", None) + if result.get("attempt_number", 0) and result.get("attempt_number", 0) > 1: + print( + f"[heartbeat_runner] step7 fallback succeeded via " + f"{result.get('provider_id')}:{result.get('model')} " + f"(attempt #{result.get('attempt_number')})", + flush=True, + ) + return result + except Exception as exc: + print( + f"[heartbeat_runner] provider_fallback unavailable ({exc}); " + f"using native claude", + flush=True, + ) + + return _step7_invoke_claude_native(agent, prompt, max_turns, timeout_seconds) + + +def _step7_invoke_claude_native( + agent: str, + prompt: str, + max_turns: int, + timeout_seconds: int, +) -> dict: + """Invoke native `claude` via subprocess with hard timeout. Returns result dict. + + Legacy direct path — used when provider fallback is disabled or unavailable. + """ import shutil claude_bin = shutil.which("claude") @@ -240,6 +296,7 @@ def step7_invoke_claude( "--max-turns", str(max_turns), "--dangerously-skip-permissions", "--output-format", "json", + "--", prompt, # positional argument — Claude CLI does not have a -p flag ] @@ -465,13 +522,48 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s task_id = None try: - # Special case: agent='system' heartbeats run a Python script directly - # instead of invoking Claude. The script path is resolved by heartbeat id. - if hb["agent"] == "system": + # Special case: legacy agent='system' heartbeats without explicit handler + # run a Python script directly, resolved by heartbeat id. + # System heartbeats with `handler` use the in-process handler path below. + if hb["agent"] == "system" and not hb.get("handler"): full_prompt = f"[system heartbeat] {heartbeat_id}" result = _run_system_heartbeat(heartbeat_id, hb["timeout_seconds"]) result["agent"] = "system" result["started_at"] = started_at + elif hb.get("handler"): + # In-process handlers do not have/need a Claude agent identity. + _handler_ref = hb.get("handler") or "" + full_prompt = f"[handler heartbeat] {heartbeat_id}" + print(f"[heartbeat_runner] step7 in-process handler={_handler_ref}", flush=True) + import importlib + import time as _time + _t0 = _time.time() + try: + _mod_name, _fn_name = _handler_ref.rsplit(".", 1) + _mod = importlib.import_module(_mod_name) + _fn = getattr(_mod, _fn_name) + _handler_result = _fn() + _duration_ms = round((_time.time() - _t0) * 1000) + result = { + "status": "success", + "error": None, + "agent": hb.get("agent", "system"), + "duration_ms": _duration_ms, + "started_at": started_at, + "handler_result": _handler_result, + } + print(f"[heartbeat_runner] step7 in-process handler done duration_ms={_duration_ms}", flush=True) + except Exception as _h_exc: + import traceback + _duration_ms = round((_time.time() - _t0) * 1000) + result = { + "status": "fail", + "error": traceback.format_exc(), + "agent": hb.get("agent", "system"), + "duration_ms": _duration_ms, + "started_at": started_at, + } + print(f"[heartbeat_runner] step7 in-process handler failed: {_h_exc}", flush=True) else: # Step 1 identity = step1_load_identity(hb["agent"]) @@ -502,57 +594,18 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s full_prompt = step6_assemble_context(identity, decision_ctx, hb.get("goal_id")) print(f"[heartbeat_runner] step6 prompt assembled ({len(full_prompt)} chars)", flush=True) - # Step 7 — in-process handler OR Claude CLI subprocess - _handler_ref = hb.get("handler") or "" - if _handler_ref: - # Wave 2.2r: in-process Python handler (e.g. plugin_integration_health.tick) - # Format: "module_name.function_name" - print(f"[heartbeat_runner] step7 in-process handler={_handler_ref}", flush=True) - import importlib - import time as _time - _t0 = _time.time() - try: - _mod_name, _fn_name = _handler_ref.rsplit(".", 1) - _mod = importlib.import_module(_mod_name) - _fn = getattr(_mod, _fn_name) - _handler_result = _fn() - _duration_ms = round((_time.time() - _t0) * 1000) - invoke_result = { - "status": "success", - "error": None, - "agent": hb.get("agent", "system"), - "duration_ms": _duration_ms, - "started_at": started_at, - "handler_result": _handler_result, - } - print(f"[heartbeat_runner] step7 in-process handler done duration_ms={_duration_ms}", flush=True) - except Exception as _h_exc: - import traceback - _duration_ms = round((_time.time() - _t0) * 1000) - invoke_result = { - "status": "fail", - "error": traceback.format_exc(), - "agent": hb.get("agent", "system"), - "duration_ms": _duration_ms, - "started_at": started_at, - } - print(f"[heartbeat_runner] step7 in-process handler failed: {_h_exc}", flush=True) - invoke_result["agent"] = hb.get("agent", "system") - invoke_result["started_at"] = started_at - result = invoke_result - else: - # Standard Claude CLI subprocess - print(f"[heartbeat_runner] step7 invoking claude agent={hb['agent']} max_turns={hb['max_turns']} timeout={hb['timeout_seconds']}s", flush=True) - invoke_result = step7_invoke_claude( - agent=hb["agent"], - prompt=full_prompt, - max_turns=hb["max_turns"], - timeout_seconds=hb["timeout_seconds"], - ) - invoke_result["agent"] = hb["agent"] - invoke_result["started_at"] = started_at - result = invoke_result - print(f"[heartbeat_runner] step7 done status={result['status']} duration_ms={result.get('duration_ms')}", flush=True) + # Step 7 — Claude CLI subprocess + print(f"[heartbeat_runner] step7 invoking claude agent={hb['agent']} max_turns={hb['max_turns']} timeout={hb['timeout_seconds']}s", flush=True) + invoke_result = step7_invoke_claude( + agent=hb["agent"], + prompt=full_prompt, + max_turns=hb["max_turns"], + timeout_seconds=hb["timeout_seconds"], + ) + invoke_result["agent"] = hb["agent"] + invoke_result["started_at"] = started_at + result = invoke_result + print(f"[heartbeat_runner] step7 done status={result['status']} duration_ms={result.get('duration_ms')}", flush=True) except Exception as exc: import traceback diff --git a/dashboard/backend/heartbeat_schema.py b/dashboard/backend/heartbeat_schema.py index 2a797d0d..0fdbf3ca 100644 --- a/dashboard/backend/heartbeat_schema.py +++ b/dashboard/backend/heartbeat_schema.py @@ -22,14 +22,15 @@ class HeartbeatConfig(BaseModel): id: Annotated[str, Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")] agent: Annotated[str, Field(min_length=1, max_length=100)] interval_seconds: Annotated[int, Field(ge=60)] - max_turns: Annotated[int, Field(ge=1, le=100)] = 10 + max_turns: Annotated[int, Field(ge=0, le=100)] = 10 timeout_seconds: Annotated[int, Field(ge=30, le=3600)] = 600 lock_timeout_seconds: Annotated[int, Field(ge=60)] = 1800 wake_triggers: Annotated[List[WakeTrigger], Field(min_length=1)] enabled: bool = False goal_id: Optional[str] = None required_secrets: List[str] = Field(default_factory=list) - decision_prompt: Annotated[str, Field(min_length=20)] + decision_prompt: str + handler: Optional[str] = None source_plugin: Optional[str] = None # AC4: set to plugin slug for plugin-contributed heartbeats @field_validator("agent") @@ -70,6 +71,18 @@ def triggers_must_be_valid(cls, v: list) -> list: @model_validator(mode="after") def interval_trigger_requires_interval_field(self) -> "HeartbeatConfig": + if self.handler: + if self.max_turns != 0: + raise ValueError("Heartbeats with handler must set max_turns=0") + return self + + if self.max_turns < 1: + raise ValueError("Claude heartbeats without handler must set max_turns >= 1") + if len(self.decision_prompt.strip()) < 20: + raise ValueError( + "Claude heartbeats without handler must provide decision_prompt " + "with at least 20 characters" + ) return self diff --git a/dashboard/backend/provider_fallback.py b/dashboard/backend/provider_fallback.py new file mode 100644 index 00000000..f31fe8c6 --- /dev/null +++ b/dashboard/backend/provider_fallback.py @@ -0,0 +1,487 @@ +"""Provider Fallback Engine — automatic 429/quota detection & provider rotation. + +Reads the active provider from config/providers.json and its fallback_models / +fallback_providers configuration. When a subprocess call returns 429/quota +errors, this module cycles to the next model (within the same provider) or +to the next provider entirely. + +Cooldown tracking prevents thrashing — a model/provider that 429'd gets a +cooldown window before we try it again. + +Usage: + from provider_fallback import FallbackEngine + + engine = FallbackEngine() + for attempt in engine.attempts(prompt, max_turns=10, timeout=600): + result = attempt.run() + if result["status"] == "success": + break + # engine automatically records 429 and advances to next model/provider +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import signal +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterator + +WORKSPACE = Path(__file__).resolve().parent.parent.parent +PROVIDERS_CONFIG = WORKSPACE / "config" / "providers.json" + +# ── Error patterns that trigger fallback ────────────────────────────────────── + +_429_PATTERNS = [ + re.compile(r"429", re.IGNORECASE), + re.compile(r"rate.?limit", re.IGNORECASE), + re.compile(r"quota", re.IGNORECASE), + re.compile(r"too many requests", re.IGNORECASE), + re.compile(r"resource.?exhausted", re.IGNORECASE), + re.compile(r"capacity", re.IGNORECASE), + re.compile(r"overloaded", re.IGNORECASE), + re.compile(r"service.?unavailable", re.IGNORECASE), + re.compile(r"temporarily.?unavailable", re.IGNORECASE), + re.compile(r"insufficient_quota", re.IGNORECASE), + re.compile(r"billing.?limit", re.IGNORECASE), + re.compile(r"plan.?limit", re.IGNORECASE), +] + +# Fatal errors that should NOT trigger fallback (auth / config issues) +_FATAL_PATTERNS = [ + re.compile(r"401", re.IGNORECASE), + re.compile(r"403", re.IGNORECASE), + re.compile(r"invalid.?api.?key", re.IGNORECASE), + re.compile(r"authentication", re.IGNORECASE), + re.compile(r"unauthorized", re.IGNORECASE), + re.compile(r"forbidden", re.IGNORECASE), +] + + +def is_429_error(error_text: str) -> bool: + """Check if error text indicates a 429/rate-limit/quota issue.""" + if not error_text: + return False + has_429 = any(p.search(error_text) for p in _429_PATTERNS) + has_fatal = any(p.search(error_text) for p in _FATAL_PATTERNS) + if has_fatal and not has_429: + return False + return has_429 + + +# ── Cooldown tracking ───────────────────────────────────────────────────────── + +_cooldowns: dict[str, float] = {} +DEFAULT_COOLDOWN_SECONDS = 300 # 5 min + + +def set_cooldown(key: str, duration_seconds: float = DEFAULT_COOLDOWN_SECONDS): + _cooldowns[key] = time.time() + duration_seconds + + +def is_on_cooldown(key: str) -> bool: + deadline = _cooldowns.get(key) + if deadline is None: + return False + if time.time() > deadline: + _cooldowns.pop(key, None) + return False + return True + + +def clear_cooldown(key: str): + _cooldowns.pop(key, None) + + +def clear_all_cooldowns(): + _cooldowns.clear() + + +# ── Config reading ───────────────────────────────────────────────────────────── + +def _read_providers_config() -> dict: + try: + if PROVIDERS_CONFIG.is_file(): + return json.loads(PROVIDERS_CONFIG.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + return {"active_provider": "nvidia", "providers": {}} + + +# ── Default fallback chains ─────────────────────────────────────────────────── + +# NVIDIA: models are independent — quota on one doesn't block others. +# Order matches the validated fast, tool-calling-capable models (and mirrors +# providers.json model_tiers / fallback_models). OpenRouter is intentionally +# NOT in the chain — its stealth models 404 intermittently ("buga mto"). +NVIDIA_MODEL_CHAIN = [ + "z-ai/glm-5.1", # Primary (sonnet tier) + "moonshotai/kimi-k2.6", # 2nd (opus tier, deep reasoning) + "qwen/qwen3.5-397b-a17b", # 3rd + "stepfun-ai/step-3.7-flash", # 4th (haiku tier, fastest) +] + +# Provider chain: NVIDIA → Codex (GPT-5.5 OAuth) → Claude nativo +DEFAULT_PROVIDER_CHAIN = [ + { + "provider_id": "nvidia", + "cli_command": "openclaude", + "base_url": "https://integrate.api.nvidia.com/v1", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + }, + "model_chain": NVIDIA_MODEL_CHAIN, + }, + { + "provider_id": "codex_auth", + "cli_command": "openclaude", + "base_url": None, + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + }, + "model_chain": ["codexplan", "codexspark"], + }, + { + "provider_id": "anthropic", + "cli_command": "claude", + "base_url": None, + "env_vars": {}, + "model_chain": [None], # claude binary uses its own model + }, +] + + +def _resolve_provider_chain(config: dict) -> list[dict]: + """Build the provider chain from config, falling back to defaults.""" + active_id = config.get("active_provider", "nvidia") + providers = config.get("providers", {}) + active_prov = providers.get(active_id, {}) + + explicit_chain = active_prov.get("fallback_providers") + if isinstance(explicit_chain, list) and explicit_chain: + chain = [_build_provider_entry(active_id, providers)] + for pid in explicit_chain: + if pid in providers: + chain.append(_build_provider_entry(pid, providers)) + return chain if len(chain) >= 2 else DEFAULT_PROVIDER_CHAIN + + return DEFAULT_PROVIDER_CHAIN + + +def _build_provider_entry(provider_id: str, providers: dict) -> dict: + prov = providers.get(provider_id, {}) + env_vars = {k: v for k, v in prov.get("env_vars", {}).items() + if v and k not in ("OPENAI_API_KEY", "OPENAI_MODEL")} + model_chain = prov.get("fallback_models", + [prov.get("default_model") or prov.get("env_vars", {}).get("OPENAI_MODEL")]) + + return { + "provider_id": provider_id, + "cli_command": prov.get("cli_command", "openclaude"), + "base_url": prov.get("default_base_url") or prov.get("env_vars", {}).get("OPENAI_BASE_URL"), + "env_vars": env_vars, + "model_chain": [m for m in model_chain if m], + } + + +def _get_api_key(provider_id: str, config: dict) -> str: + prov = config.get("providers", {}).get(provider_id, {}) + env_vars = prov.get("env_vars", {}) + for key_name in ("OPENAI_API_KEY", "NVIDIA_API_KEY", "GEMINI_API_KEY"): + val = env_vars.get(key_name, "") + if val and "****" not in val: + return val + return os.environ.get("OPENAI_API_KEY", "") + + +# ── Attempt record ───────────────────────────────────────────────────────────── + +@dataclass +class FallbackAttempt: + attempt_number: int + provider_id: str + model: str | None + cli_command: str + prompt: str + max_turns: int + timeout_seconds: int + env_overrides: dict = field(default_factory=dict) + _result: dict | None = field(default=None, repr=False) + + def run(self) -> dict: + self._result = _invoke_cli( + cli_command=self.cli_command, + prompt=self.prompt, + max_turns=self.max_turns, + timeout_seconds=self.timeout_seconds, + env_overrides=self.env_overrides, + ) + return self._result + + @property + def result(self) -> dict | None: + return self._result + + @property + def is_429(self) -> bool: + if not self._result: + return False + error = self._result.get("error") or "" + output = self._result.get("output") or "" + return is_429_error(error) or is_429_error(output) + + @property + def is_fatal(self) -> bool: + if not self._result: + return False + if self._result.get("status") == "success": + return False + if self.is_429: + return False + return True + + +# ── Core invocation ───────────────────────────────────────────────────────────── + +def _invoke_cli( + cli_command: str, + prompt: str, + max_turns: int, + timeout_seconds: int, + env_overrides: dict | None = None, +) -> dict: + cli_bin = shutil.which(cli_command) + if not cli_bin: + return { + "status": "fail", + "error": f"{cli_command} binary not found in PATH", + "output": "", + "duration_ms": 0, + "tokens_in": None, "tokens_out": None, "cost_usd": None, + } + + cmd = [cli_bin, "--print", "--max-turns", str(max_turns), + "--dangerously-skip-permissions", "--output-format", "json", "--", prompt] + + run_env = dict(os.environ) + if env_overrides: + for k, v in env_overrides.items(): + if v is not None: + run_env[k] = str(v) + + start_time = time.time() + proc = None + output = "" + error = None + status = "success" + + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, cwd=str(WORKSPACE), start_new_session=True, env=run_env, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout_seconds) + output = stdout or "" + if proc.returncode != 0: + status = "fail" + error = stderr[:2000] if stderr else f"exit code {proc.returncode}" + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, OSError): + proc.kill() + try: + proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + pass + status = "timeout" + error = f"Killed after {timeout_seconds}s timeout" + except Exception as exc: + status = "fail" + error = str(exc) + + duration_ms = int((time.time() - start_time) * 1000) + + # The CLI emits a JSON result envelope (--output-format json) carrying token + # usage and cost — parse it so heartbeat runs land accurate numbers on the + # /costs page instead of nulls. Best-effort: never let parsing break a run. + tokens_in = tokens_out = cost_usd = None + try: + envelope = json.loads(output) + usage = envelope.get("usage") or {} + tokens_in = usage.get("input_tokens") + tokens_out = usage.get("output_tokens") + cost_usd = envelope.get("total_cost_usd") + except (json.JSONDecodeError, AttributeError, TypeError): + pass + + return { + "status": status, "output": output, "error": error, + "duration_ms": duration_ms, + "tokens_in": tokens_in, "tokens_out": tokens_out, "cost_usd": cost_usd, + } + + +# ── Fallback Engine ───────────────────────────────────────────────────────────── + +class FallbackEngine: + """Iterates through provider + model chains, falling back on 429/quota errors.""" + + def __init__(self, provider_chain: list[dict] | None = None): + if provider_chain is None: + config = _read_providers_config() + provider_chain = _resolve_provider_chain(config) + self.provider_chain = provider_chain + self._attempts_log: list[FallbackAttempt] = [] + + def attempts( + self, + prompt: str, + max_turns: int = 10, + timeout_seconds: int = 600, + agent: str = "", + force_provider: str | None = None, + force_model: str | None = None, + ) -> Iterator[FallbackAttempt]: + config = _read_providers_config() + attempt_num = 0 + + chain = self.provider_chain + if force_provider: + chain = [p for p in chain if p["provider_id"] == force_provider] + if not chain and force_provider == "nvidia": + chain = [DEFAULT_PROVIDER_CHAIN[0]] + + for provider_entry in chain: + provider_id = provider_entry["provider_id"] + cli_command = provider_entry["cli_command"] + base_url = provider_entry.get("base_url") + model_chain = provider_entry.get("model_chain", [None]) + base_env = dict(provider_entry.get("env_vars", {})) + + if base_url: + base_env["OPENAI_BASE_URL"] = base_url + + api_key = _get_api_key(provider_id, config) + if api_key: + base_env["OPENAI_API_KEY"] = api_key + # openclaude ≥0.18 detects the NVIDIA NIM base URL and requires + # the key in NVIDIA_API_KEY — derive it so auth doesn't fail and + # wrongly skip to the next provider. Mirrors provider-config.js. + if "nvidia.com" in (base_url or "") and "NVIDIA_API_KEY" not in base_env: + base_env["NVIDIA_API_KEY"] = api_key + + for model in model_chain: + if force_model and model != force_model: + continue + + cooldown_key = f"{provider_id}:{model}" if model else provider_id + if is_on_cooldown(cooldown_key): + continue + + # Provider-level cooldown (NVIDIA exempt — models independent) + if is_on_cooldown(provider_id) and provider_id != "nvidia": + continue + + attempt_num += 1 + + env_overrides = dict(base_env) + if model: + env_overrides["OPENAI_MODEL"] = model + + attempt = FallbackAttempt( + attempt_number=attempt_num, + provider_id=provider_id, + model=model, + cli_command=cli_command, + prompt=prompt, + max_turns=max_turns, + timeout_seconds=timeout_seconds, + env_overrides=env_overrides, + ) + self._attempts_log.append(attempt) + yield attempt + + # After caller runs the attempt, check result + if attempt.result is None: + continue + + if attempt.result.get("status") == "success": + return + + if attempt.is_429: + set_cooldown(cooldown_key) + print(f"[fallback] 429 on {provider_id}:{model} — " + f"cooldown {DEFAULT_COOLDOWN_SECONDS}s, trying next", flush=True) + continue + + if attempt.is_fatal: + print(f"[fallback] fatal error on {provider_id}:{model} — " + f"skipping to next provider", flush=True) + break + + continue + + @property + def attempts_log(self) -> list[FallbackAttempt]: + return list(self._attempts_log) + + def last_successful_attempt(self) -> FallbackAttempt | None: + for a in reversed(self._attempts_log): + if a.result and a.result.get("status") == "success": + return a + return None + + +# ── Convenience: single call with automatic fallback ─────────────────────────── + +def invoke_with_fallback( + prompt: str, + max_turns: int = 10, + timeout_seconds: int = 600, + agent: str = "", + force_provider: str | None = None, + force_model: str | None = None, +) -> dict: + """Invoke CLI with automatic 429 fallback. Returns the first successful result.""" + engine = FallbackEngine() + last_result = None + + for attempt in engine.attempts( + prompt=prompt, max_turns=max_turns, timeout_seconds=timeout_seconds, + agent=agent, force_provider=force_provider, force_model=force_model, + ): + result = attempt.run() + result["provider_id"] = attempt.provider_id + result["model"] = attempt.model + result["attempt_number"] = attempt.attempt_number + + if result["status"] == "success": + if attempt.attempt_number > 1: + print(f"[fallback] SUCCESS on attempt #{attempt.attempt_number} " + f"({attempt.provider_id}:{attempt.model})", flush=True) + return result + + last_result = result + print(f"[fallback] attempt #{attempt.attempt_number} failed " + f"({attempt.provider_id}:{attempt.model}) status={result['status']}", flush=True) + + if last_result: + last_result["fallback_exhausted"] = True + last_result["total_attempts"] = len(engine.attempts_log) + else: + last_result = { + "status": "fail", + "error": "No attempts made — all providers on cooldown or no chain configured", + "output": "", "duration_ms": 0, + "fallback_exhausted": True, "total_attempts": 0, + } + + return last_result diff --git a/dashboard/backend/routes/providers.py b/dashboard/backend/routes/providers.py index a2127ef8..c7b03978 100644 --- a/dashboard/backend/routes/providers.py +++ b/dashboard/backend/routes/providers.py @@ -49,6 +49,7 @@ "CODEX_API_KEY", "GEMINI_API_KEY", "GEMINI_MODEL", + "NVIDIA_API_KEY", "AWS_REGION", "AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_VERTEX_PROJECT_ID", @@ -317,6 +318,15 @@ def update_provider_config(provider_id): # Reject values with shell metacharacters if not isinstance(value, str) or re.search(r'[;&|`$\n\r]', value): continue + # Never wipe a stored secret with an empty field — the UI submits + # blank password inputs when the user edits other fields (e.g. model), + # which would silently erase the saved key. + if ( + value == "" + and existing.get(key) + and any(tag in key for tag in ("KEY", "SECRET", "TOKEN")) + ): + continue existing[key] = value provider["env_vars"] = existing diff --git a/dashboard/backend/routes/routines.py b/dashboard/backend/routes/routines.py index 0a535d95..0cc86ac9 100644 --- a/dashboard/backend/routes/routines.py +++ b/dashboard/backend/routes/routines.py @@ -124,6 +124,8 @@ def list_adws(): "flux-2": {"per_image": 0.03, "input_per_1m": 0, "output_per_1m": 0}, "flux.2": {"per_image": 0.03, "input_per_1m": 0, "output_per_1m": 0}, "gpt-5-image": {"per_image": 0.04, "input_per_1m": 0.005, "output_per_1m": 0.015}, + # OpenAI Images API — billed per token (text input / image output rates). + "gpt-image-2": {"per_image": 0.0, "input_per_1m": 5.0, "output_per_1m": 40.0}, "seedream": {"per_image": 0.02, "input_per_1m": 0, "output_per_1m": 0}, "riverflow": {"per_image": 0.02, "input_per_1m": 0, "output_per_1m": 0}, } @@ -147,7 +149,13 @@ def _estimate_image_cost(entry: dict) -> float: return 0.03 cost = pricing["per_image"] - if total_tokens > 0 and pricing["input_per_1m"] > 0: + prompt_tokens = tokens.get("prompt_tokens", 0) + completion_tokens = tokens.get("completion_tokens", 0) + if prompt_tokens and completion_tokens and pricing.get("output_per_1m", 0) > 0: + # Split billing: text input vs image output rates (OpenAI Images API) + cost += (prompt_tokens / 1_000_000) * pricing["input_per_1m"] + cost += (completion_tokens / 1_000_000) * pricing["output_per_1m"] + elif total_tokens > 0 and pricing["input_per_1m"] > 0: cost += (total_tokens / 1_000_000) * pricing["input_per_1m"] return round(cost, 6) diff --git a/dashboard/frontend/src/components/AgentChat.tsx b/dashboard/frontend/src/components/AgentChat.tsx index b60ff664..a6e24666 100644 --- a/dashboard/frontend/src/components/AgentChat.tsx +++ b/dashboard/frontend/src/components/AgentChat.tsx @@ -137,28 +137,34 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e setStatus('connecting') setErrorMsg(null) let cancelled = false - let ws: WebSocket | null = null + let reconnectTimer: ReturnType | null = null + let reconnectAttempts = 0 + + // The server keeps the session alive when the socket drops — rejoining + // restores chat history, so a dead WS only needs a reconnect with + // capped exponential backoff instead of staying dead until remount. + function scheduleReconnect() { + if (cancelled || reconnectTimer) return + const delay = Math.min(1000 * 2 ** reconnectAttempts, 15000) + reconnectAttempts++ + setStatus('connecting') + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, delay) + } - ;(async () => { - // 1) HTTP preflight — fails fast on ECONNREFUSED so we can show a real error - // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). - try { - const res = await fetch(`${TS_HTTP}/api/health`) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - } catch { - if (cancelled) return - setStatus('error') - setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) - return - } + function connect() { if (cancelled) return - // 2) Open WS - ws = new WebSocket(`${TS_WS}/ws`) + // Open WS + const ws = new WebSocket(`${TS_WS}/ws`) wsRef.current = ws ws.onopen = () => { - ws!.send(JSON.stringify({ type: 'join_session', sessionId })) + reconnectAttempts = 0 + setErrorMsg(null) + ws.send(JSON.stringify({ type: 'join_session', sessionId })) setStatus('idle') } @@ -273,26 +279,60 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e } ws.onerror = () => { - if (cancelled) return - setStatus('error') - setErrorMsg('WebSocket error') + // onclose always follows onerror — reconnect is handled there. } ws.onclose = () => { if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } + if (cancelled) return + scheduleReconnect() } pingRef.current = setInterval(() => { - if (ws!.readyState === WebSocket.OPEN) { - ws!.send(JSON.stringify({ type: 'ping' })) + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) } }, 25000) + } + + ;(async () => { + // HTTP preflight — fails fast on ECONNREFUSED so we can show a real error + // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). + try { + const res = await fetch(`${TS_HTTP}/api/health`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch { + if (cancelled) return + setStatus('error') + setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) + return + } + if (cancelled) return + connect() })() + // Returning to the tab reconnects immediately instead of waiting out + // the backoff (browsers also throttle timers in hidden tabs, so the + // pending reconnect may not have fired while the tab was away). + const onVisible = () => { + if (document.hidden || cancelled) return + const ws = wsRef.current + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempts = 0 + connect() + } + document.addEventListener('visibilitychange', onVisible) + return () => { cancelled = true + document.removeEventListener('visibilitychange', onVisible) + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } - try { ws?.close() } catch {} + try { wsRef.current?.close() } catch {} wsRef.current = null } }, [sessionId]) diff --git a/dashboard/frontend/src/components/AgentTerminal.tsx b/dashboard/frontend/src/components/AgentTerminal.tsx index ef438a0a..56957dce 100644 --- a/dashboard/frontend/src/components/AgentTerminal.tsx +++ b/dashboard/frontend/src/components/AgentTerminal.tsx @@ -184,6 +184,24 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor const term = termRef.current if (!term) return + let reconnectTimer: ReturnType | null = null + let reconnectAttempts = 0 + let alreadyActive = false + + // The server keeps the pty alive when the socket drops, so a dead WS + // only needs a rejoin — reconnect with capped exponential backoff + // instead of leaving the terminal dead until the component remounts. + function scheduleReconnect(sessionId: string) { + if (cancelled || reconnectTimer) return + const delay = Math.min(1000 * 2 ** reconnectAttempts, 15000) + reconnectAttempts++ + setStatus('connecting') + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect(sessionId, true) + }, delay) + } + async function run() { setStatus('connecting') setErrorMsg(null) @@ -191,7 +209,6 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor // 1) Use provided sessionId or find-or-create for this agent let sessionId: string - let alreadyActive = false try { if (externalSessionId) { // Use the specific session provided by the parent (multi-tab mode) @@ -223,11 +240,18 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor if (cancelled) return sessionIdRef.current = sessionId + connect(sessionId, false) + } + + function connect(sessionId: string, isReconnect: boolean) { + if (cancelled) return + // 2) Open WS const ws = new WebSocket(`${CC_WEB_WS}/ws`) wsRef.current = ws ws.onopen = () => { + setErrorMsg(null) ws.send(JSON.stringify({ type: 'join_session', sessionId })) } @@ -238,12 +262,18 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor switch (msg.type) { case 'session_joined': { + reconnectAttempts = 0 + // On reconnect the server replays the whole buffer — clear + // first so it doesn't duplicate what's already on screen. + if (isReconnect) term!.clear() // Replay any buffered output if (Array.isArray(msg.outputBuffer)) { msg.outputBuffer.forEach((chunk: string) => term!.write(chunk)) } - // If an agent is already running in this session, just attach - if (msg.active || alreadyActive) { + // If an agent is already running in this session, just attach. + // alreadyActive is only trustworthy on the first join — after a + // reconnect the process may have died while we were away. + if (msg.active || (!isReconnect && alreadyActive)) { setStatus('running') // Nudge a resize so the pty matches the current terminal size const fit = fitRef.current @@ -251,6 +281,11 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor try { fit.fit() } catch {} ws.send(JSON.stringify({ type: 'resize', cols: term!.cols, rows: term!.rows })) } + } else if (isReconnect) { + // Process ended while we were disconnected — surface it + // instead of silently restarting the agent. + setStatus('exited') + term!.write('\r\n\x1b[33m[Reconnected — process is no longer running]\x1b[0m\r\n') } else { // Start Claude with --agent // Pass cols/rows up-front so the pty is born at the right @@ -304,9 +339,7 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor } ws.onerror = () => { - if (cancelled) return - setStatus('error') - setErrorMsg('WebSocket error') + // onclose always follows onerror — reconnect is handled there. } ws.onclose = () => { @@ -314,6 +347,8 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor clearInterval(pingRef.current) pingRef.current = null } + if (cancelled) return + scheduleReconnect(sessionId) } // Keepalive @@ -324,10 +359,33 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor }, 25000) } + // Returning to the tab reconnects immediately instead of waiting out + // the backoff (browsers also throttle timers in hidden tabs, so the + // pending reconnect may not have fired while the tab was away). + const onVisible = () => { + if (document.hidden || cancelled) return + const sessionId = sessionIdRef.current + if (!sessionId) return + const ws = wsRef.current + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempts = 0 + connect(sessionId, true) + } + document.addEventListener('visibilitychange', onVisible) + run() return () => { cancelled = true + document.removeEventListener('visibilitychange', onVisible) + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } if (pingRef.current) { clearInterval(pingRef.current) pingRef.current = null diff --git a/dashboard/frontend/src/pages/Costs.tsx b/dashboard/frontend/src/pages/Costs.tsx index 8687e939..944726e4 100644 --- a/dashboard/frontend/src/pages/Costs.tsx +++ b/dashboard/frontend/src/pages/Costs.tsx @@ -132,6 +132,38 @@ interface ImageCosts { totals: { count: number; total_tokens: number; total_seconds: number; total_bytes: number; total_cost_usd?: number } } +// Entries logged by older skill versions miss token_usage.total_tokens and +// other fields — unguarded access crashed the whole page render. +function normalizeImageCosts(raw: any): ImageCosts | null { + if (!raw || !Array.isArray(raw.entries)) return null + const entries: ImageCostEntry[] = raw.entries.map((e: any) => ({ + timestamp: typeof e?.timestamp === 'string' ? e.timestamp : '', + model: typeof e?.model === 'string' ? e.model : 'unknown', + provider: typeof e?.provider === 'string' ? e.provider : '', + mode: typeof e?.mode === 'string' ? e.mode : '', + output_file: typeof e?.output_file === 'string' ? e.output_file : '', + size_bytes: Number(e?.size_bytes || 0), + elapsed_seconds: Number(e?.elapsed_seconds || 0), + token_usage: { + prompt_tokens: Number(e?.token_usage?.prompt_tokens || 0), + completion_tokens: Number(e?.token_usage?.completion_tokens || 0), + total_tokens: Number(e?.token_usage?.total_tokens || 0), + }, + estimated_cost_usd: typeof e?.estimated_cost_usd === 'number' ? e.estimated_cost_usd : undefined, + })) + const t = raw.totals || {} + return { + entries, + totals: { + count: Number(t.count ?? entries.length), + total_tokens: Number(t.total_tokens || 0), + total_seconds: Number(t.total_seconds || 0), + total_bytes: Number(t.total_bytes || 0), + total_cost_usd: typeof t.total_cost_usd === 'number' ? t.total_cost_usd : undefined, + }, + } +} + function relativeTime(ts: string): string { try { const diff = Date.now() - new Date(ts).getTime() @@ -162,7 +194,7 @@ export default function Costs() { api.get('/routines/image-costs').catch(() => null), ]).then(([costRaw, imgRaw]) => { if (costRaw) setData(normalizeCostData(costRaw)) - if (imgRaw) setImageCosts(imgRaw) + if (imgRaw) setImageCosts(normalizeImageCosts(imgRaw)) }).finally(() => setLoading(false)) }, []) diff --git a/dashboard/terminal-server/src/chat-bridge.js b/dashboard/terminal-server/src/chat-bridge.js index caf138b1..feb50e56 100644 --- a/dashboard/terminal-server/src/chat-bridge.js +++ b/dashboard/terminal-server/src/chat-bridge.js @@ -9,6 +9,7 @@ const os = require('os'); const { loadProviderConfig, resolveProviderModel, + getProviderMode, } = require('./provider-config'); let sdkModule = null; @@ -157,6 +158,90 @@ function resolveClaudeExecutable() { return _claudeExecutablePath; } +/** + * Resolve the CLI binary for external (non-Anthropic) providers. + * Mirrors ClaudeBridge.findClaudeCommand: shell `which` first, then hardcoded + * paths. Returns null when nothing is found — the SDK needs a real path, so + * the caller falls back to the plain chat-completion session. + * Hardcoded dispatch per command to satisfy semgrep. + */ +const _cliBinaryCache = new Map(); +function resolveCliBinary(cliCommand) { + if (_cliBinaryCache.has(cliCommand)) return _cliBinaryCache.get(cliCommand); + + const { execSync } = require('child_process'); + try { + let resolved; + if (cliCommand === 'openclaude') { + resolved = execSync('which openclaude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + } else { + resolved = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + } + if (resolved) { + console.log(`[chat-bridge] Found ${cliCommand} at: ${resolved}`); + _cliBinaryCache.set(cliCommand, resolved); + return resolved; + } + } catch { /* which failed — try hardcoded paths */ } + + const home = process.env.HOME || '/'; + const candidates = cliCommand === 'openclaude' + ? [ + path.join(home, '.local', 'bin', 'openclaude'), + '/usr/local/bin/openclaude', + '/usr/bin/openclaude', + ] + : [ + path.join(home, '.claude', 'local', 'claude'), + path.join(home, '.local', 'bin', 'claude'), + '/usr/local/bin/claude', + '/usr/bin/claude', + ]; + for (const p of candidates) { + try { + if (fs.existsSync(p)) { + console.log(`[chat-bridge] Found ${cliCommand} at hardcoded path: ${p}`); + _cliBinaryCache.set(cliCommand, p); + return p; + } + } catch { continue; } + } + + console.error(`[chat-bridge] ${cliCommand} not found anywhere`); + _cliBinaryCache.set(cliCommand, null); + return null; +} + +/** + * Build the environment for an external-provider CLI process. + * Same approach as ClaudeBridge: whitelist essential system vars (no full + * process.env spread — stale OPENAI_API_KEY would override Codex OAuth) and + * layer the provider's env_vars on top. The SDK passes this object as the + * child process env verbatim. + */ +const PROVIDER_SYSTEM_VARS = [ + 'HOME', 'USER', 'SHELL', 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', + 'LOGNAME', 'HOSTNAME', 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', + 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'TMPDIR', + 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', + 'NVM_DIR', 'NVM_BIN', 'NVM_INC', + 'CODEX_HOME', 'CLAUDE_CONFIG_DIR', +]; +function buildProviderEnv(providerConfig) { + const env = {}; + for (const key of PROVIDER_SYSTEM_VARS) { + if (process.env[key]) env[key] = process.env[key]; + } + const providerEnv = { ...(providerConfig.env_vars || {}) }; + // Same model defaults as ClaudeBridge — codexplan/codexspark route to the + // Codex backend; a raw gpt-5.x would silently bypass Codex OAuth. + if (!providerEnv.OPENAI_MODEL) { + if (providerConfig.active === 'codex_auth') providerEnv.OPENAI_MODEL = 'codexplan'; + else if (providerConfig.active === 'openai') providerEnv.OPENAI_MODEL = 'gpt-4.1'; + } + return { ...env, ...providerEnv }; +} + /** * Scan a tool_result text for a ticket-creation response. * Returns the ticket id if a POST /api/tickets response is detected, else null. @@ -241,6 +326,11 @@ function detectCreatedTicketId(text) { class ChatBridge { constructor() { this.sessions = new Map(); // sessionId -> { query, abortController, active, sdkSessionId } + // Rate limiting state for OpenAI-compatible providers + this._lastRequestTime = 0; + this._minIntervalMs = parseInt(process.env.CHAT_MIN_INTERVAL_MS || '2000', 10); + this._maxRetries = parseInt(process.env.CHAT_MAX_RETRIES || '3', 10); + this._baseDelayMs = parseInt(process.env.CHAT_BASE_DELAY_MS || '5000', 10); } _buildChatCompletionSystemPrompt(agentName, cwd, sessionId) { @@ -274,6 +364,53 @@ class ChatBridge { return ''; } + /** + * Enforce minimum interval between requests, then fetch with retry/backoff. + * Retries on 429 (rate limited) and 503 (upstream unavailable). + */ + async _rateLimitedFetch(url, options) { + // Enforce minimum interval between requests + const now = Date.now(); + const elapsed = now - this._lastRequestTime; + if (elapsed < this._minIntervalMs) { + const wait = this._minIntervalMs - elapsed; + console.log(`[chat-bridge] Rate limit: aguardando ${wait}ms antes do próximo request...`); + await new Promise((r) => setTimeout(r, wait)); + } + + let lastError = null; + for (let attempt = 0; attempt <= this._maxRetries; attempt++) { + this._lastRequestTime = Date.now(); + + const resp = await fetch(url, options); + + if (resp.ok) return resp; + + const isRetryable = resp.status === 429 || resp.status === 503; + if (!isRetryable || attempt === this._maxRetries) { + return resp; // let caller handle non-retryable errors or final failure + } + + // Exponential backoff: base * 2^attempt (5s, 10s, 20s) + const delay = this._baseDelayMs * Math.pow(2, attempt); + let retryAfter = delay; + const retryAfterHeader = resp.headers.get('Retry-After'); + if (retryAfterHeader) { + const parsed = parseInt(retryAfterHeader, 10); + if (!isNaN(parsed)) retryAfter = parsed * 1000; + } + + console.warn( + `[chat-bridge] ${resp.status} no request (tentativa ${attempt + 1}/${this._maxRetries + 1}). ` + + `Aguardando ${Math.round(retryAfter / 1000)}s antes de tentar novamente...` + ); + await new Promise((r) => setTimeout(r, retryAfter)); + } + + // Should not reach here, but return last error response + throw new Error(`_rateLimitedFetch: todas as ${this._maxRetries + 1} tentativas falharam`); + } + async _startOpenAICompatibleSession(sessionId, options, providerConfig) { const { agentName, @@ -316,7 +453,7 @@ class ChatBridge { (async () => { try { - const resp = await fetch(`${baseUrl}/chat/completions`, { + const resp = await this._rateLimitedFetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, @@ -408,8 +545,20 @@ class ChatBridge { } = options; const providerConfig = loadProviderConfig(); - if (providerConfig.active !== 'anthropic') { - return this._startOpenAICompatibleSession(sessionId, options, providerConfig); + const isExternalProvider = !!providerConfig.active && providerConfig.active !== 'anthropic'; + let externalCliPath = null; + if (isExternalProvider) { + // Code-mode providers go through the Agent SDK with the openclaude + // binary — full tool calling, streaming, session resume. Only genuinely + // chat-completion-only models keep the plain REST path. + if (getProviderMode(providerConfig) !== 'code') { + return this._startOpenAICompatibleSession(sessionId, options, providerConfig); + } + externalCliPath = resolveCliBinary(providerConfig.cli_command || 'openclaude'); + if (!externalCliPath) { + console.warn(`[chat-bridge] ${providerConfig.cli_command} binary not found — falling back to chat completion. Install with: npm install -g @gitlawb/openclaude`); + return this._startOpenAICompatibleSession(sessionId, options, providerConfig); + } } const { query: sdkQuery } = await loadSDK(); @@ -426,8 +575,14 @@ class ChatBridge { abortController, }; - const claudeExe = resolveClaudeExecutable(); - if (claudeExe) queryOptions.pathToClaudeCodeExecutable = claudeExe; + if (isExternalProvider) { + queryOptions.pathToClaudeCodeExecutable = externalCliPath; + queryOptions.env = buildProviderEnv(providerConfig); + console.log(`[chat-bridge] External provider "${providerConfig.active}" via ${externalCliPath} (model: ${resolveProviderModel(providerConfig) || 'default'})`); + } else { + const claudeExe = resolveClaudeExecutable(); + if (claudeExe) queryOptions.pathToClaudeCodeExecutable = claudeExe; + } // Load agent definition from .claude/agents/{name}.md if (agentName) { @@ -458,13 +613,26 @@ class ChatBridge { if (systemPromptExtras && !sdkSessionId) { promptAppend = promptAppend + '\n\n' + systemPromptExtras; } - queryOptions.systemPrompt = { - type: 'preset', - preset: 'claude_code', - append: promptAppend, - }; - if (agentDef.model) queryOptions.model = agentDef.model; - console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt.append (${agentDef.prompt.length} chars, model: ${agentDef.model || 'inherit'})`); + if (isExternalProvider) { + // Mirror ClaudeBridge: --append-system-prompt is too weak for GPT + // models, so REPLACE the system prompt to force the agent persona. + // agentDef.model is a Claude model name — meaningless here; the + // model comes from the provider's OPENAI_MODEL env var. + queryOptions.systemPrompt = promptAppend + '\n\n' + + 'CRITICAL: You MUST fully embody this agent persona. ' + + 'You are NOT Claude, OpenClaude, or a generic assistant — you ARE ' + agentName + '. ' + + 'When asked who you are, ALWAYS respond as ' + agentName + '. ' + + 'Never break character. Follow ALL instructions above.'; + console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt replace (${agentDef.prompt.length} chars, external provider)`); + } else { + queryOptions.systemPrompt = { + type: 'preset', + preset: 'claude_code', + append: promptAppend, + }; + if (agentDef.model) queryOptions.model = agentDef.model; + console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt.append (${agentDef.prompt.length} chars, model: ${agentDef.model || 'inherit'})`); + } } else { console.warn(`[chat-bridge] Agent "${agentName}" not found, running without agent`); } diff --git a/dashboard/terminal-server/src/claude-bridge.js b/dashboard/terminal-server/src/claude-bridge.js index baa68ae4..af792a1e 100644 --- a/dashboard/terminal-server/src/claude-bridge.js +++ b/dashboard/terminal-server/src/claude-bridge.js @@ -1,6 +1,9 @@ const { spawn } = require('node-pty'); const path = require('path'); const fs = require('fs'); + +// Workspace root is three levels up from this file (dashboard/terminal-server/src/). +const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..'); const { loadProviderConfig, resolveProviderModel, @@ -71,6 +74,30 @@ class ClaudeBridge { return cliCommand; } + /** + * True when the CLI has a persisted conversation file for this session id + * in this workingDir — i.e. a previous run got far enough to save state, + * so `--resume ` will succeed. Checks the config dirs used by both + * claude (~/.claude) and openclaude (~/.openclaude), plus + * CLAUDE_CONFIG_DIR when set. + */ + _hasPersistedConversation(sessionId, workingDir) { + const home = process.env.HOME || '/'; + const slug = String(workingDir).replace(/[^a-zA-Z0-9]/g, '-'); + const configDirs = [ + process.env.CLAUDE_CONFIG_DIR, + path.join(home, '.openclaude'), + path.join(home, '.claude'), + ].filter(Boolean); + return configDirs.some((dir) => { + try { + return fs.existsSync(path.join(dir, 'projects', slug, `${sessionId}.jsonl`)); + } catch { + return false; + } + }); + } + async startSession(sessionId, options = {}) { if (this.sessions.has(sessionId)) { const existing = this.sessions.get(sessionId); @@ -147,15 +174,27 @@ class ClaudeBridge { // --system-prompt REPLACES the default system prompt, ensuring the agent persona // takes priority over CLAUDE.md and other context that mentions "Claude". const active = providerConfig.active || 'anthropic'; + let agentTier = null; if (active !== 'anthropic' && agent) { - // Read the agent definition file to build a strong system prompt - const agentFile = path.join(workingDir, '.claude', 'agents', `${agent}.md`); + // Read the agent definition file to build a strong system prompt. + // Agent definitions live at the workspace root — workingDir varies per + // session (tickets, project folders), so prefer the root path. + const rootAgentFile = path.join(WORKSPACE_ROOT, '.claude', 'agents', `${agent}.md`); + const cwdAgentFile = path.join(workingDir, '.claude', 'agents', `${agent}.md`); + const agentFile = fs.existsSync(rootAgentFile) ? rootAgentFile : cwdAgentFile; let agentPrompt = ''; try { const content = fs.readFileSync(agentFile, 'utf8'); // Extract body (after YAML frontmatter ---) const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); agentPrompt = match ? match[1].trim() : content; + // Extract the agent's model tier (opus|sonnet|haiku) from the + // frontmatter — used to pick a per-tier provider model below. + const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (fmMatch) { + const tierMatch = fmMatch[1].match(/^model:\s*["']?([a-z0-9.-]+)["']?\s*$/mi); + if (tierMatch) agentTier = tierMatch[1].toLowerCase(); + } } catch { agentPrompt = `You are the ${agent} agent.`; } @@ -168,7 +207,48 @@ class ClaudeBridge { args.push('--system-prompt', enforcePrompt); } - const providerEnv = providerConfig.env_vars || {}; + + // Pin the CLI conversation to the terminal-server session UUID so a + // crash, provider error, or terminal-server restart doesn't lose the + // conversation: the first start registers the id with --session-id, + // and any later start of the same session resumes it with --resume. + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (UUID_RE.test(sessionId)) { + if (this._hasPersistedConversation(sessionId, workingDir)) { + console.log(`[bridge] Resuming persisted conversation for session ${sessionId}`); + args.push('--resume', sessionId); + } else { + args.push('--session-id', sessionId); + } + } + + // Copy so per-session overrides don't leak into the shared config object. + const providerEnv = { ...(providerConfig.env_vars || {}) }; + + // Per-agent model tier: agents declare model: opus|sonnet|haiku in + // their frontmatter; providers.json maps each tier to a provider model + // via the provider's "model_tiers" field. + const tierModel = agentTier && providerConfig.model_tiers + ? providerConfig.model_tiers[agentTier] + : null; + if (active !== 'anthropic' && tierModel) { + providerEnv['OPENAI_MODEL'] = tierModel; + console.log(`[provider] Agent ${agent} tier "${agentTier}" → model ${tierModel}`); + } + + // Automatic model fallback when the primary is overloaded. The CLI + // accepts a single fallback — pass the first configured entry that + // differs from the primary model. + const fallbackModels = Array.isArray(providerConfig.fallback_models) + ? providerConfig.fallback_models + : []; + if (active !== 'anthropic') { + const primary = providerEnv['OPENAI_MODEL'] || ''; + const fallback = fallbackModels.find((m) => m && m !== primary); + if (fallback) { + args.push('--fallback-model', fallback); + } + } // Build a CLEAN environment for the spawned CLI process. // We DON'T spread process.env — it may contain stale/cached vars @@ -192,7 +272,7 @@ class ClaudeBridge { // to route to the Codex backend — a raw 'gpt-5.x' falls back to the // regular chat completions API, which bypasses Codex OAuth entirely. // - // codexplan → GPT-5.4 on Codex backend (high reasoning) + // codexplan → GPT-5.5 on Codex backend (high reasoning) // codexspark → GPT-5.3 Codex Spark (faster) // // For the plain 'openai' provider (API key mode), default to gpt-4.1. diff --git a/dashboard/terminal-server/src/provider-config.js b/dashboard/terminal-server/src/provider-config.js index 2065d480..33c0d75e 100644 --- a/dashboard/terminal-server/src/provider-config.js +++ b/dashboard/terminal-server/src/provider-config.js @@ -5,6 +5,7 @@ const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..'); const PROVIDERS_PATH = path.join(WORKSPACE_ROOT, 'config', 'providers.json'); const ALLOWED_CLI = new Set(['claude', 'openclaude']); +const ALLOWED_MODES = new Set(['code', 'chat']); const ALLOWED_ENV_VARS = new Set([ 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_USE_OPENAI', @@ -18,6 +19,7 @@ const ALLOWED_ENV_VARS = new Set([ 'CODEX_API_KEY', 'GEMINI_API_KEY', 'GEMINI_MODEL', + 'NVIDIA_API_KEY', 'AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK', 'ANTHROPIC_VERTEX_PROJECT_ID', @@ -57,6 +59,9 @@ function resolveProviderModel(providerConfig) { function getProviderMode(providerConfig) { const active = providerConfig?.active || 'anthropic'; if (active === 'anthropic') return 'anthropic'; + // Explicit per-provider mode in providers.json wins over the name heuristic — + // model names like "openrouter/owl-alpha" are agentic but don't match isCodeModel. + if (ALLOWED_MODES.has(providerConfig?.mode)) return providerConfig.mode; const model = resolveProviderModel(providerConfig); if (isCodeModel(model)) return 'code'; return 'chat'; @@ -65,7 +70,7 @@ function getProviderMode(providerConfig) { function loadProviderConfig() { try { if (!fs.existsSync(PROVIDERS_PATH)) { - return { cli_command: 'claude', env_vars: {}, active: 'anthropic' }; + return { cli_command: 'claude', env_vars: {}, active: 'anthropic', fallback_models: [], model_tiers: {} }; } const config = JSON.parse(fs.readFileSync(PROVIDERS_PATH, 'utf8')); @@ -85,14 +90,46 @@ function loadProviderConfig() { delete envVars.OPENAI_API_KEY; } + // OpenClaude ≥0.18 detects the NVIDIA NIM base URL and requires the key + // in NVIDIA_API_KEY — derive it so the UI only asks for one key field. + if ( + !envVars.NVIDIA_API_KEY && + envVars.OPENAI_API_KEY && + /\bnvidia\.com\b/i.test(envVars.OPENAI_BASE_URL || '') + ) { + envVars.NVIDIA_API_KEY = envVars.OPENAI_API_KEY; + } + + // Ordered fallback chain — the CLI consumes the first entry + // (--fallback-model); the rest stay available for future chain support. + const fallbackModels = Array.isArray(provider.fallback_models) + ? provider.fallback_models + .filter((m) => typeof m === 'string' && m.trim()) + .map((m) => m.trim()) + : []; + + // Per-tier model map: agents declare model: opus|sonnet|haiku in their + // frontmatter; providers.json maps each tier to a provider model. + const modelTiers = {}; + if (provider.model_tiers && typeof provider.model_tiers === 'object' && !Array.isArray(provider.model_tiers)) { + for (const [tier, model] of Object.entries(provider.model_tiers)) { + if (typeof model === 'string' && model.trim()) { + modelTiers[tier.toLowerCase()] = model.trim(); + } + } + } + return { cli_command: cliCommand, env_vars: envVars, active, provider_name: provider.name || active, + mode: ALLOWED_MODES.has(provider.mode) ? provider.mode : null, + fallback_models: fallbackModels, + model_tiers: modelTiers, }; } catch { - return { cli_command: 'claude', env_vars: {}, active: 'anthropic' }; + return { cli_command: 'claude', env_vars: {}, active: 'anthropic', fallback_models: [], model_tiers: {} }; } } diff --git a/dashboard/terminal-server/test/provider-config.test.js b/dashboard/terminal-server/test/provider-config.test.js new file mode 100644 index 00000000..87dd251c --- /dev/null +++ b/dashboard/terminal-server/test/provider-config.test.js @@ -0,0 +1,47 @@ +const assert = require('assert/strict'); +const test = require('node:test'); + +const { + getProviderMode, + isCodeModel, +} = require('../src/provider-config'); + +test('getProviderMode returns anthropic for the native provider', () => { + assert.equal(getProviderMode({ active: 'anthropic' }), 'anthropic'); +}); + +test('getProviderMode falls back to the model-name heuristic', () => { + assert.equal( + getProviderMode({ active: 'openrouter', env_vars: { OPENAI_MODEL: 'qwen-coder' } }), + 'code' + ); + assert.equal( + getProviderMode({ active: 'openrouter', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }), + 'chat' + ); +}); + +test('explicit mode overrides the model-name heuristic', () => { + assert.equal( + getProviderMode({ active: 'openrouter', mode: 'code', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }), + 'code' + ); + assert.equal( + getProviderMode({ active: 'openrouter', mode: 'chat', env_vars: { OPENAI_MODEL: 'qwen-coder' } }), + 'chat' + ); +}); + +test('invalid mode values are ignored', () => { + assert.equal( + getProviderMode({ active: 'openrouter', mode: 'bogus', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }), + 'chat' + ); +}); + +test('isCodeModel recognizes codex aliases and coder names', () => { + assert.equal(isCodeModel('codexplan'), true); + assert.equal(isCodeModel('codexspark'), true); + assert.equal(isCodeModel('devstral-small'), true); + assert.equal(isCodeModel('gpt-4.1'), false); +}); diff --git a/uv.lock b/uv.lock index 69360e19..b720c143 100644 --- a/uv.lock +++ b/uv.lock @@ -560,6 +560,18 @@ nvtx = [ { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -589,7 +601,7 @@ wheels = [ [[package]] name = "evo-nexus" -version = "0.32.2" +version = "0.33.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -599,6 +611,7 @@ dependencies = [ { name = "cryptography" }, { name = "flask" }, { name = "flask-cors" }, + { name = "flask-limiter" }, { name = "flask-login" }, { name = "flask-sock" }, { name = "flask-sqlalchemy" }, @@ -634,6 +647,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=42" }, { name = "flask", specifier = ">=3.0" }, { name = "flask-cors", specifier = ">=4.0" }, + { name = "flask-limiter", specifier = ">=3.5" }, { name = "flask-login", specifier = ">=0.6" }, { name = "flask-sock", specifier = ">=0.7" }, { name = "flask-sqlalchemy", specifier = ">=3.1" }, @@ -718,6 +732,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, ] +[[package]] +name = "flask-limiter" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "limits" }, + { name = "ordered-set" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/98/71780be5d1afb941219c4b48d241d4e246e3062b017caa4e79c4dc71314c/flask_limiter-4.1.1.tar.gz", hash = "sha256:ca11608fc7eec43dcea606964ca07c3bd4ec1ae89043a0f67f717899a4f48106", size = 403198, upload-time = "2025-12-06T17:39:00.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/7c/9fe9ffc83be199011bb0c6deb82cdcbc5a355601e380581de9dbc30490dd/flask_limiter-4.1.1-py3-none-any.whl", hash = "sha256:e1ae13e06e6b3e39a4902e7d240b901586b25932c2add7bd5f5eeb4bdc11111b", size = 30554, upload-time = "2025-12-06T17:38:59.162Z" }, +] + [[package]] name = "flask-login" version = "0.6.3" @@ -1155,6 +1184,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "mako" version = "1.3.11" @@ -1723,6 +1766,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" }, ] +[[package]] +name = "ordered-set" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -3480,6 +3532,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "wsproto" version = "1.3.2"