From 14b86782d3ae51086a7564aa7e4606fd016c68a3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:54:34 +0530 Subject: [PATCH] feat: add Fireworks AI as built-in provider Add fireworks as a first-class provider with full parity to existing API-key providers (gemini, cerebras, zai, etc.): - PROVIDER_REGISTRY entry with FIREWORKS_API_KEY env var - Provider aliases: fireworks-ai, fw -> fireworks - models.dev integration (14 models with context lengths) - Dynamic model discovery (no static model list) - URL-to-provider mapping already existed (api.fireworks.ai -> fireworks) - models_dev.py mapping already existed (fireworks -> fireworks-ai) - Passthrough model normalization - Default auxiliary model (accounts/fireworks/models/llama3.1-8b) - HermesOverlay in new providers.py system - CLI --provider choices, setup flow, config docs - 30 provider-specific tests --- .env.example | 9 + agent/auxiliary_client.py | 1 + agent/model_metadata.py | 3 +- cli-config.yaml.example | 2 + hermes_cli/auth.py | 10 + hermes_cli/config.py | 16 ++ hermes_cli/main.py | 6 +- hermes_cli/model_normalize.py | 1 + hermes_cli/models.py | 5 +- hermes_cli/providers.py | 10 + tests/hermes_cli/test_fireworks_provider.py | 224 ++++++++++++++++++++ 11 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_fireworks_provider.py diff --git a/.env.example b/.env.example index 02d059194ab2..038d38d17670 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,15 @@ # Optional base URL override (default: Google's OpenAI-compatible endpoint) # GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai +# ============================================================================= +# LLM PROVIDER (Fireworks AI) +# ============================================================================= +# Fast serverless inference. OpenAI-compatible endpoint. +# Get your key at: https://fireworks.ai/account/api-keys +# FIREWORKS_API_KEY=your_fireworks_key_here +# Optional base URL override (default: https://api.fireworks.ai/inference/v1) +# FIREWORKS_BASE_URL=https://api.fireworks.ai/inference/v1 + # ============================================================================= # LLM PROVIDER (z.ai / GLM) # ============================================================================= diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4b156a4e6eab..780b858e5a00 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -106,6 +106,7 @@ def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", "kilocode": "google/gemini-3-flash-preview", + "fireworks": "accounts/fireworks/models/llama3.1-8b", } # OpenRouter app attribution headers diff --git a/agent/model_metadata.py b/agent/model_metadata.py index a5fb11f50305..2d6cff46bb5c 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -24,13 +24,14 @@ # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek", + "gemini", "fireworks", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek", "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "custom", "local", # Common aliases "google", "google-gemini", "google-ai-studio", "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", "github-models", "kimi", "moonshot", "claude", "deep-seek", + "fw", "fireworks-ai", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", }) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 73bff981f9c8..07b6b69933fa 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -24,6 +24,7 @@ model: # "minimax" - MiniMax global (requires: MINIMAX_API_KEY) # "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY) # "huggingface" - Hugging Face Inference (requires: HF_TOKEN) + # "fireworks" - Fireworks AI (requires: FIREWORKS_API_KEY — https://fireworks.ai/account/api-keys) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY) # @@ -317,6 +318,7 @@ compression: # "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) # "nous" - Force Nous Portal (requires: hermes login) # "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) +# "fireworks" - Fireworks AI (requires: FIREWORKS_API_KEY) # "codex" - Force Codex OAuth (requires: hermes model → Codex). # Uses gpt-5.3-codex which supports vision. # "main" - Use your custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY). diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 2025bbcc88c5..3953b2df7dc8 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -70,6 +70,7 @@ DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" +DEFAULT_FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1" CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 @@ -233,6 +234,14 @@ class ProviderConfig: api_key_env_vars=("HF_TOKEN",), base_url_env_var="HF_BASE_URL", ), + "fireworks": ProviderConfig( + id="fireworks", + name="Fireworks AI", + auth_type="api_key", + inference_base_url=DEFAULT_FIREWORKS_BASE_URL, + api_key_env_vars=("FIREWORKS_API_KEY",), + base_url_env_var="FIREWORKS_BASE_URL", + ), } @@ -820,6 +829,7 @@ def resolve_provider( "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "go": "opencode-go", "opencode-go-sub": "opencode-go", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", + "fireworks-ai": "fireworks", "fw": "fireworks", # Local server aliases — route through the generic custom provider "lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom", "ollama": "custom", "vllm": "custom", "llamacpp": "custom", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3338a13c4895..a628a10246b7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -771,6 +771,22 @@ def ensure_hermes_home(): "category": "provider", "advanced": True, }, + "FIREWORKS_API_KEY": { + "description": "Fireworks AI API key (fast serverless inference)", + "prompt": "Fireworks AI API key", + "url": "https://fireworks.ai/account/api-keys", + "password": True, + "category": "provider", + "advanced": True, + }, + "FIREWORKS_BASE_URL": { + "description": "Fireworks AI base URL override (default: https://api.fireworks.ai/inference/v1)", + "prompt": "Fireworks base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, # ── Tool API keys ── "EXA_API_KEY": { diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3d1e28476808..08969d746abd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -932,6 +932,7 @@ def select_provider_and_model(args=None): "kilocode": "Kilo Code", "alibaba": "Alibaba Cloud (DashScope)", "huggingface": "Hugging Face", + "fireworks": "Fireworks AI", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) if active else "none" @@ -963,6 +964,7 @@ def select_provider_and_model(args=None): ("opencode-go", "OpenCode Go (open models, $10/month subscription)"), ("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"), ("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), + ("fireworks", "Fireworks AI (fast serverless inference — OpenAI-compatible)"), ] # Add user-defined custom providers from config.yaml @@ -1057,7 +1059,7 @@ def select_provider_and_model(args=None): _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) - elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"): + elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "fireworks"): _model_flow_api_key_provider(config, selected_provider, current_model) @@ -4240,7 +4242,7 @@ def main(): ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"], + choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "fireworks", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"], default=None, help="Inference provider (default: auto)" ) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 378e1e192395..193c5f727335 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -85,6 +85,7 @@ "minimax-cn", "alibaba", "huggingface", + "fireworks", "openai-codex", "custom", }) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 4b37bc9e73bd..4868620b7262 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -480,6 +480,7 @@ def check_nous_free_tier() -> bool: "kilocode": "Kilo Code", "alibaba": "Alibaba Cloud (DashScope)", "huggingface": "Hugging Face", + "fireworks": "Fireworks AI", "custom": "Custom endpoint", } @@ -521,6 +522,8 @@ def check_nous_free_tier() -> bool: "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", + "fireworks-ai": "fireworks", + "fw": "fireworks", } @@ -761,7 +764,7 @@ def list_available_providers() -> list[dict[str, str]]: # Canonical providers in display order _PROVIDER_ORDER = [ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "huggingface", + "gemini", "fireworks", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba", "opencode-zen", "opencode-go", "ai-gateway", "deepseek", "custom", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 5cd02380c95a..731b1b4650bb 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -121,6 +121,10 @@ class HermesOverlay: is_aggregator=True, base_url_env_var="HF_BASE_URL", ), + "fireworks": HermesOverlay( + transport="openai_chat", + base_url_env_var="FIREWORKS_BASE_URL", + ), } @@ -200,6 +204,10 @@ def is_user_defined(self) -> bool: # deepseek "deep-seek": "deepseek", + # fireworks + "fireworks-ai": "fireworks", + "fw": "fireworks", + # alibaba "dashscope": "alibaba", "aliyun": "alibaba", @@ -232,6 +240,7 @@ def is_user_defined(self) -> bool: "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", "local": "Local endpoint", + "fireworks": "Fireworks AI", } @@ -367,6 +376,7 @@ def get_label(provider_id: str) -> str: "huggingface": "Hugging Face", "local": "Local endpoint", "custom": "Custom endpoint", + "fireworks": "Fireworks AI", # Legacy Hermes IDs (point to same providers) "ai-gateway": "Vercel AI Gateway", "kilocode": "Kilo Gateway", diff --git a/tests/hermes_cli/test_fireworks_provider.py b/tests/hermes_cli/test_fireworks_provider.py new file mode 100644 index 000000000000..2ec662f8d3a3 --- /dev/null +++ b/tests/hermes_cli/test_fireworks_provider.py @@ -0,0 +1,224 @@ +"""Tests for Fireworks AI provider integration.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + +from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials +from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider +from hermes_cli.model_normalize import normalize_model_for_provider +from agent.model_metadata import _URL_TO_PROVIDER, _PROVIDER_PREFIXES +from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models + + +# ── Provider Registry ── + +class TestFireworksProviderRegistry: + def test_in_registry(self): + assert "fireworks" in PROVIDER_REGISTRY + + def test_config(self): + pconfig = PROVIDER_REGISTRY["fireworks"] + assert pconfig.id == "fireworks" + assert pconfig.name == "Fireworks AI" + assert pconfig.auth_type == "api_key" + assert pconfig.inference_base_url == "https://api.fireworks.ai/inference/v1" + + def test_env_vars(self): + pconfig = PROVIDER_REGISTRY["fireworks"] + assert pconfig.api_key_env_vars == ("FIREWORKS_API_KEY",) + assert pconfig.base_url_env_var == "FIREWORKS_BASE_URL" + + def test_base_url(self): + assert "api.fireworks.ai" in PROVIDER_REGISTRY["fireworks"].inference_base_url + + +# ── Provider Aliases ── + +PROVIDER_ENV_VARS = ( + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", "GEMINI_API_KEY", "FIREWORKS_API_KEY", + "GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", + "MINIMAX_API_KEY", "DEEPSEEK_API_KEY", +) + +@pytest.fixture(autouse=True) +def _clean_provider_env(monkeypatch): + for var in PROVIDER_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +class TestFireworksAliases: + def test_explicit(self): + assert resolve_provider("fireworks") == "fireworks" + + def test_alias_fireworks_ai(self): + assert resolve_provider("fireworks-ai") == "fireworks" + + def test_alias_fw(self): + assert resolve_provider("fw") == "fireworks" + + def test_models_py_aliases(self): + assert _PROVIDER_ALIASES.get("fireworks-ai") == "fireworks" + assert _PROVIDER_ALIASES.get("fw") == "fireworks" + + def test_normalize_provider(self): + assert normalize_provider("fireworks") == "fireworks" + assert normalize_provider("fw") == "fireworks" + assert normalize_provider("fireworks-ai") == "fireworks" + + +# ── Auto-detection ── + +class TestFireworksAutoDetection: + def test_auto_detects_api_key(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "test-key") + assert resolve_provider("auto") == "fireworks" + + +# ── Credential Resolution ── + +class TestFireworksCredentials: + def test_resolve_with_api_key(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw-secret") + creds = resolve_api_key_provider_credentials("fireworks") + assert creds["provider"] == "fireworks" + assert creds["api_key"] == "fw-secret" + assert creds["base_url"] == "https://api.fireworks.ai/inference/v1" + + def test_resolve_with_custom_base_url(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "key") + monkeypatch.setenv("FIREWORKS_BASE_URL", "https://custom.fw/v1") + creds = resolve_api_key_provider_credentials("fireworks") + assert creds["base_url"] == "https://custom.fw/v1" + + def test_runtime(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="fireworks") + assert result["provider"] == "fireworks" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "fw-key" + assert result["base_url"] == "https://api.fireworks.ai/inference/v1" + + +# ── Model Catalog (dynamic) ── + +class TestFireworksModelCatalog: + def test_no_static_model_list(self): + """Fireworks models are discovered dynamically via models.dev + live API.""" + assert "fireworks" not in _PROVIDER_MODELS + + def test_provider_label(self): + assert "fireworks" in _PROVIDER_LABELS + assert _PROVIDER_LABELS["fireworks"] == "Fireworks AI" + + +# ── Model Normalization ── + +class TestFireworksModelNormalization: + def test_passthrough(self): + """Fireworks uses vendor-prefixed model names as-is.""" + model = "accounts/fireworks/models/gpt-oss-120b" + assert normalize_model_for_provider(model, "fireworks") == model + + def test_passthrough_short(self): + assert normalize_model_for_provider("llama3.1-8b", "fireworks") == "llama3.1-8b" + + +# ── URL-to-Provider Mapping ── + +class TestFireworksUrlMapping: + def test_url_to_provider(self): + assert _URL_TO_PROVIDER.get("api.fireworks.ai") == "fireworks" + + def test_provider_prefix_canonical(self): + assert "fireworks" in _PROVIDER_PREFIXES + + def test_provider_prefix_alias_fw(self): + assert "fw" in _PROVIDER_PREFIXES + + def test_provider_prefix_alias_fireworks_ai(self): + assert "fireworks-ai" in _PROVIDER_PREFIXES + + +# ── models.dev Integration ── + +class TestFireworksModelsDev: + def test_mapped(self): + assert PROVIDER_TO_MODELS_DEV.get("fireworks") == "fireworks-ai" + + def test_list_agentic_models_with_mock_data(self): + mock_data = { + "fireworks-ai": { + "models": { + "accounts/fireworks/models/gpt-oss-120b": {"tool_call": True}, + "accounts/fireworks/models/llama3.1-8b": {"tool_call": True}, + "accounts/fireworks/models/some-embedding": {"tool_call": False}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_data): + result = list_agentic_models("fireworks") + assert "accounts/fireworks/models/gpt-oss-120b" in result + assert "accounts/fireworks/models/llama3.1-8b" in result + assert "accounts/fireworks/models/some-embedding" not in result + + +# ── Agent Init ── + +class TestFireworksAgentInit: + def test_agent_imports_without_error(self): + import importlib + import run_agent + importlib.reload(run_agent) + + def test_uses_chat_completions(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "test-key") + with patch("run_agent.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from run_agent import AIAgent + agent = AIAgent( + model="accounts/fireworks/models/gpt-oss-120b", + provider="fireworks", + api_key="test-key", + base_url="https://api.fireworks.ai/inference/v1", + ) + assert agent.api_mode == "chat_completions" + assert agent.provider == "fireworks" + + +# ── providers.py New System ── + +class TestFireworksProvidersNew: + def test_overlay_exists(self): + from hermes_cli.providers import HERMES_OVERLAYS + assert "fireworks" in HERMES_OVERLAYS + overlay = HERMES_OVERLAYS["fireworks"] + assert overlay.transport == "openai_chat" + assert overlay.base_url_env_var == "FIREWORKS_BASE_URL" + + def test_alias_resolves(self): + from hermes_cli.providers import normalize_provider as np + assert np("fireworks") == "fireworks" + assert np("fw") == "fireworks" + assert np("fireworks-ai") == "fireworks" + + def test_label(self): + from hermes_cli.providers import get_label + assert get_label("fireworks") == "Fireworks AI" + + def test_get_provider(self): + from hermes_cli.providers import get_provider + pdef = get_provider("fireworks") + assert pdef is not None + assert pdef.id == "fireworks" + assert pdef.transport == "openai_chat" + + +# ── Auxiliary Model ── + +class TestFireworksAuxiliary: + def test_aux_model_defined(self): + from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS + assert "fireworks" in _API_KEY_PROVIDER_AUX_MODELS