From c97d9a4c07b27c8ce94c10e83b916eea43a1e257 Mon Sep 17 00:00:00 2001 From: Alex Jestin Taylor Date: Wed, 8 Jul 2026 15:32:46 -0700 Subject: [PATCH] feat(providers): add Fireworks AI as preferred provider Bundle Fireworks AI as a first-class BYOK provider across the CLI, web/TUI, and desktop onboarding. - New model-provider plugin with attribution headers (HTTP-Referer / X-Title) so Fireworks can attribute Hermes traffic; PAYG-safe default aux + fallback models (accounts/fireworks/models/...), IDs tracking fw-ai/fireconnect. - Registered in CANONICAL_PROVIDERS so it appears in the CLI/web/TUI pickers. - Alias wiring (fireworks-ai, fw) into both CLI resolvers. - First-class wiring: OPTIONAL_ENV_VARS, HERMES_OVERLAYS (FIREWORKS_BASE_URL override), doctor env hints. Live catalog + model_metadata are auto-derived. - doctor: treat Fireworks' native slash-form IDs (accounts/fireworks/...) as valid, not aggregator vendor prefixes, so it no longer tells Fireworks users to switch to openrouter or drop the prefix. - picker: plugin providers with no static curated list now lead with their profile fallback_models, so the default is an agentic chat model instead of whatever the live catalog returns first (Fireworks listed an image model, flux-*, ahead of its chat models). - Desktop onboarding: Fireworks as a RECOMMENDED hero card with the official Fireworks logomark and a brand-purple badge, routing to the BYOK key form; i18n in en/ja/zh/zh-hant. - Tests: profile contract, first-class wiring (both resolvers, overlay, config, doctor incl. the slash-form regression, aux headers, credentials), discovery spot-check, and a live smoke test driven through the Hermes runtime. Fire Pass (fpk_) support is coming soon; the future wiring is kept as a commented-out scaffold in the plugin. --- .env.example | 10 + apps/desktop/public/fireworks-white.svg | 5 + apps/desktop/public/fireworks.svg | 5 + .../src/components/onboarding/index.test.tsx | 22 +- .../src/components/onboarding/index.tsx | 34 ++- .../src/components/onboarding/providers.tsx | 28 +++ apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/ja.ts | 5 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + hermes_cli/config.py | 16 ++ hermes_cli/doctor.py | 5 + hermes_cli/models.py | 15 +- hermes_cli/providers.py | 10 + plugins/model-providers/fireworks/__init__.py | 68 ++++++ plugins/model-providers/fireworks/plugin.yaml | 5 + tests/hermes_cli/test_fireworks_provider.py | 203 ++++++++++++++++++ .../model_providers/test_fireworks_profile.py | 85 ++++++++ tests/providers/test_plugin_discovery.py | 2 +- tests/run_agent/test_fireworks_live.py | 66 ++++++ 21 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/public/fireworks-white.svg create mode 100644 apps/desktop/public/fireworks.svg create mode 100644 plugins/model-providers/fireworks/__init__.py create mode 100644 plugins/model-providers/fireworks/plugin.yaml create mode 100644 tests/hermes_cli/test_fireworks_provider.py create mode 100644 tests/plugins/model_providers/test_fireworks_profile.py create mode 100644 tests/run_agent/test_fireworks_live.py diff --git a/.env.example b/.env.example index 4c83db1f3b4..0e78d2023f9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,16 @@ # Hermes Agent Environment Configuration # Copy this file to .env and fill in your API keys +# ============================================================================= +# LLM PROVIDER (Fireworks AI) — preferred BYOK provider +# ============================================================================= +# Fireworks AI — fastest inference for production AI, 300+ models, one API key. +# Get your key at: https://app.fireworks.ai/settings/users/api-keys +# Address models directly by catalog ID, e.g. +# accounts/fireworks/models/kimi-k2p6, accounts/fireworks/models/glm-5p2 +# FIREWORKS_API_KEY= +# FIREWORKS_BASE_URL=https://api.fireworks.ai/inference/v1 # Override default base URL + # ============================================================================= # LLM PROVIDER (OpenRouter) # ============================================================================= diff --git a/apps/desktop/public/fireworks-white.svg b/apps/desktop/public/fireworks-white.svg new file mode 100644 index 00000000000..cfab71d67ff --- /dev/null +++ b/apps/desktop/public/fireworks-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/public/fireworks.svg b/apps/desktop/public/fireworks.svg new file mode 100644 index 00000000000..f392879f60c --- /dev/null +++ b/apps/desktop/public/fireworks.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/src/components/onboarding/index.test.tsx b/apps/desktop/src/components/onboarding/index.test.tsx index 35cac9c4d17..dd9028943fd 100644 --- a/apps/desktop/src/components/onboarding/index.test.tsx +++ b/apps/desktop/src/components/onboarding/index.test.tsx @@ -56,12 +56,15 @@ afterEach(() => { }) describe('onboarding Picker', () => { - it('features Nous Portal and hides other providers behind a disclosure', () => { + it('features Nous Portal and Fireworks, and hides other providers behind a disclosure', () => { setProviders([provider('anthropic', 'Anthropic Claude'), provider('nous', 'Nous Portal')]) render() expect(screen.getByText('Nous Portal')).toBeTruthy() - expect(screen.getByText('Recommended')).toBeTruthy() + // Fireworks is the preferred BYOK provider — a second hero card after Nous. + expect(screen.getByText('Fireworks AI')).toBeTruthy() + // Both hero cards carry a Recommended badge; the plain rows below do not. + expect(screen.getAllByText('Recommended')).toHaveLength(2) expect(screen.queryByText('Anthropic API Key')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Other providers' })) @@ -70,14 +73,25 @@ describe('onboarding Picker', () => { expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy() }) - it('shows every provider directly when Nous Portal is absent', () => { + it('still features Fireworks when Nous Portal is absent', () => { setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')]) render() expect(screen.getByText('Anthropic API Key')).toBeTruthy() expect(screen.getByText('OpenAI OAuth (ChatGPT)')).toBeTruthy() expect(screen.queryByText('Other sign-in options')).toBeNull() - expect(screen.queryByText('Recommended')).toBeNull() + // Fireworks stays featured even without Nous — exactly one Recommended badge. + expect(screen.getByText('Fireworks AI')).toBeTruthy() + expect(screen.getAllByText('Recommended')).toHaveLength(1) + }) + + it('routes the Fireworks hero card into the API-key form', () => { + setProviders([provider('nous', 'Nous Portal')]) + render() + + fireEvent.click(screen.getByText('Fireworks AI')) + + expect($desktopOnboarding.get().mode).toBe('apikey') }) it('offers "choose later" on first run and persists the skip', () => { diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 0b81db3775b..8c889ed20f6 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -28,9 +28,16 @@ import { import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' import { DocsLink, FlowPanel, Status } from './flow' -import { FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers' +import { FeaturedFireworksRow, FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers' -export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers' +export { + FeaturedFireworksRow, + FeaturedProviderRow, + KeyProviderRow, + ProviderRow, + providerTitle, + sortProviders +} from './providers' interface DesktopOnboardingOverlayProps { enabled: boolean @@ -49,6 +56,12 @@ export interface ApiKeyOption { } const API_KEY_OPTIONS: ApiKeyOption[] = [ + { + id: 'fireworks', + name: 'Fireworks AI', + envKey: 'FIREWORKS_API_KEY', + docsUrl: 'https://app.fireworks.ai/settings/users/api-keys' + }, { id: 'openrouter', name: 'OpenRouter', @@ -395,6 +408,14 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { const { t } = useI18n() const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding) const [showAll, setShowAll] = useState(readShowAll) + // Which key-form option to preselect when we flip to 'apikey' mode. Set by the + // Fireworks hero card (its BYOK key) and the OpenRouter row; the generic + // "I have an API key" link leaves it undefined and lands on the first option. + const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState(undefined) + const openKeyForm = (envKey?: string) => { + setApiKeyInitialEnv(envKey) + setOnboardingMode('apikey') + } const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 const apiKeyOptions = useApiKeyCatalog() @@ -408,7 +429,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
setOnboardingMode('oauth')} onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)} options={apiKeyOptions} @@ -438,12 +459,15 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
{featured ? : null} + {/* Fireworks — preferred BYOK provider, second hero card after Nous. + Routes into the API-key form preselecting the Fireworks key. */} + openKeyForm('FIREWORKS_API_KEY')} /> {showRest ? ( <> {rest.map(p => ( ))} - setOnboardingMode('apikey')} /> + openKeyForm('OPENROUTER_API_KEY')} /> ) : null}
@@ -466,7 +490,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { {manual ? : } + ) +} + function ConnectedTag() { const { t } = useI18n() diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index b4520ec13c6..246beec9bcf 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1928,8 +1928,13 @@ export const en: Translations = { recommended: 'Recommended', connected: 'Connected', featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes', + fireworksPitch: 'Fastest inference for production AI — 300+ models, one API, deploy in a click', openRouterPitch: 'One key, hundreds of models — a solid default', apiKeyOptions: { + fireworks: { + short: 'fastest inference', + description: 'Direct access to 300+ open & proprietary models on Fireworks AI — one API key, production speed.' + }, openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 10088833cd6..0efe672db9e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1873,8 +1873,13 @@ export const ja = defineLocale({ recommended: '推奨', connected: '接続済み', featuredPitch: '1 つのサブスクリプションで 300 以上の最先端モデル — Hermes を実行するための推奨方法', + fireworksPitch: 'プロダクション AI 向けの最速推論 — 300 以上のモデル、1 つの API、ワンクリックでデプロイ', openRouterPitch: '1 つのキーで数百のモデル — 堅実なデフォルト', apiKeyOptions: { + fireworks: { + short: '最速の推論', + description: 'Fireworks AI で 300 以上のオープン/プロプライエタリモデルに直接アクセス — 1 つの API キー、プロダクション速度。' + }, openrouter: { short: '1 つのキーで多くのモデル', description: '1 つのキーで数百のモデルをホスト。新規インストールのデフォルトとして最適。' diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 031f5c78f6d..ff19f13191b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1582,6 +1582,7 @@ export interface Translations { recommended: string connected: string featuredPitch: string + fireworksPitch: string openRouterPitch: string apiKeyOptions: Record backToSignIn: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5dbec605e90..008bf7def57 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1817,8 +1817,10 @@ export const zhHant = defineLocale({ recommended: '建議', connected: '已連線', featuredPitch: '一個訂閱,300+ 前沿模型 — 執行 Hermes 的建議方式', + fireworksPitch: '面向生產 AI 的最快推理 — 300+ 模型,一個 API,一鍵部署', openRouterPitch: '一個金鑰,數百個模型 — 穩定的預設選擇', apiKeyOptions: { + fireworks: { short: '最快推理', description: '透過 Fireworks AI 直接存取 300+ 開源與專有模型 — 一個 API 金鑰,生產級速度。' }, openrouter: { short: '一個金鑰,多個模型', description: '用一個金鑰存取數百個模型。適合新安裝的預設選擇。' }, openai: { short: 'GPT 等級模型', description: '直接存取 OpenAI 模型。' }, gemini: { short: 'Gemini 模型', description: '直接存取 Google Gemini 模型。' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 350a50311e0..4a6a9ca9241 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2100,8 +2100,10 @@ export const zh: Translations = { recommended: '推荐', connected: '已连接', featuredPitch: '一个订阅,300+ 前沿模型 — 运行 Hermes 的推荐方式', + fireworksPitch: '面向生产 AI 的最快推理 — 300+ 模型,一个 API,一键部署', openRouterPitch: '一个密钥,数百个模型 — 稳妥的默认选择', apiKeyOptions: { + fireworks: { short: '最快推理', description: '通过 Fireworks AI 直接访问 300+ 开源与专有模型 — 一个 API 密钥,生产级速度。' }, openrouter: { short: '一个密钥,多个模型', description: '用一个密钥访问数百个模型。适合新安装的默认选择。' }, openai: { short: 'GPT 级模型', description: '直接访问 OpenAI 模型。' }, gemini: { short: 'Gemini 模型', description: '直接访问 Google Gemini 模型。' }, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 981533f15e7..31fd9dbc764 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3535,6 +3535,22 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "FIREWORKS_API_KEY": { + "description": "Fireworks AI API key", + "prompt": "Fireworks AI API key", + "url": "https://app.fireworks.ai/settings/users/api-keys", + "password": True, + "category": "provider", + "advanced": True, + }, + "FIREWORKS_BASE_URL": { + "description": "Fireworks AI base URL override", + "prompt": "Fireworks AI base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "MINIMAX_API_KEY": { "description": "MiniMax API key (international)", "prompt": "MiniMax API key", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 12b688b224c..4122e7af295 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -42,6 +42,7 @@ _PROVIDER_ENV_HINTS = ( "KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", + "FIREWORKS_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", @@ -845,6 +846,10 @@ def run_doctor(args): "lmstudio", "nous", "nvidia", + # Fireworks' native model IDs are slash-form + # (accounts/fireworks/models/... and .../routers/...), so a "/" + # is expected, not an aggregator vendor prefix. + "fireworks", } provider_accepts_vendor_slug = ( provider_policy_id in providers_accepting_vendor_slugs diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 3fee59e7fea..54df537b8ed 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1051,6 +1051,9 @@ class ProviderEntry(NamedTuple): CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("nous", "Nous Portal", "Nous Portal (Everything your agent needs, 300+ models with bundled tool use)"), + # Fireworks AI — preferred BYOK inference partner. Sits directly below Nous + # Portal (Nous's own provider stays first); everything else follows. + ProviderEntry("fireworks", "Fireworks AI", "Fireworks AI (Fastest inference for production AI, 300+ models, one API key)"), ProviderEntry("openrouter", "OpenRouter", "OpenRouter (Pay-per-use API aggregator)"), ProviderEntry("moa", "Mixture of Agents", "Mixture of Agents (named presets; aggregator acts after reference models)"), ProviderEntry("novita", "NovitaAI", "NovitaAI (Cloud: Model API, Agent Sandbox, GPU Cloud)"), @@ -1243,6 +1246,8 @@ _PROVIDER_ALIASES = { "arceeai": "arcee", "gmi-cloud": "gmi", "gmicloud": "gmi", + "fireworks-ai": "fireworks", + "fw": "fireworks", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "minimax-portal": "minimax-oauth", @@ -2491,7 +2496,15 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # live API is the authoritative catalog, so they merge # live-first — live entries lead and stale curated entries # no longer pollute the top of the picker. (#49129) - curated = list(_PROVIDER_MODELS.get(normalized, [])) + # + # Plugin providers with no static _PROVIDER_MODELS entry fall + # back to the profile's curated fallback_models so their + # agentic picks lead the picker instead of whatever the live + # catalog happens to return first (e.g. Fireworks lists an + # image model, flux-*, ahead of its chat models). + curated = list(_PROVIDER_MODELS.get(normalized, [])) or list( + _p.fallback_models or () + ) if curated: if normalized in _LIVE_FIRST_PICKER_PROVIDERS: primary, secondary = live, curated diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 0c2a4518315..6d73683717a 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -196,6 +196,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_override="https://api.gmi-serving.com/v1", base_url_env_var="GMI_BASE_URL", ), + "fireworks": HermesOverlay( + transport="openai_chat", + extra_env_vars=("FIREWORKS_API_KEY",), + base_url_override="https://api.fireworks.ai/inference/v1", + base_url_env_var="FIREWORKS_BASE_URL", + ), "ollama-cloud": HermesOverlay( transport="openai_chat", base_url_override="https://ollama.com/v1", @@ -343,6 +349,10 @@ ALIASES: Dict[str, str] = { "gmi-cloud": "gmi", "gmicloud": "gmi", + # fireworks + "fireworks-ai": "fireworks", + "fw": "fireworks", + # Local server aliases → virtual "local" concept (resolved via user config) "lmstudio": "lmstudio", "lm-studio": "lmstudio", diff --git a/plugins/model-providers/fireworks/__init__.py b/plugins/model-providers/fireworks/__init__.py new file mode 100644 index 00000000000..55b9f37da19 --- /dev/null +++ b/plugins/model-providers/fireworks/__init__.py @@ -0,0 +1,68 @@ +"""Fireworks AI provider profile. + +Fireworks AI serves fast, production-grade inference for open and proprietary +models through an OpenAI-compatible chat-completions endpoint. It is a bundled, +preferred BYOK provider — users paste a Fireworks API key and go. + +Address models directly by their catalog ID, e.g. +``accounts/fireworks/models/kimi-k2p6`` or ``accounts/fireworks/models/glm-5p2``. +Model IDs here track the canonical Fireworks catalog (fw-ai/fireconnect +``setup-cli``). +""" + +from providers import register_provider +from providers.base import ProviderProfile + + +fireworks = ProviderProfile( + name="fireworks", + aliases=("fireworks-ai", "fw"), + display_name="Fireworks AI", + description="Fireworks AI — fastest inference for production AI, 300+ models, one API key", + signup_url="https://app.fireworks.ai/settings/users/api-keys", + env_vars=("FIREWORKS_API_KEY", "FIREWORKS_BASE_URL"), + base_url="https://api.fireworks.ai/inference/v1", + auth_type="api_key", + # Attribution: lets Fireworks identify traffic originating from Hermes Agent + # (partner/revenue attribution). Matches the canonical Hermes attribution + # values in agent/auxiliary_client.py. Applied at client construction via the + # generic profile.default_headers path in run_agent.py. + default_headers={ + "HTTP-Referer": "https://hermes-agent.nousresearch.com", + "X-Title": "Hermes Agent", + }, + # Auxiliary model for cheap tasks (compaction, title generation, vision). + # A standard pay-as-you-go catalog ``/models/`` ID. + default_aux_model="accounts/fireworks/models/glm-5p2", + # Curated safety net shown in the picker when the live catalog fetch fails. + fallback_models=( + "accounts/fireworks/models/kimi-k2p6", + "accounts/fireworks/models/glm-5p2", + "accounts/fireworks/models/kimi-k2p7-code", + ), +) + +register_provider(fireworks) + + +# --------------------------------------------------------------------------- +# Future work (intentionally disabled for now — do not ship as customer-facing). +# +# A second Fireworks key tier authenticates against the same endpoint but can +# only address managed router IDs (accounts/fireworks/routers/...), not raw +# /models/ IDs, and cannot list the account catalog. Enabling it means detecting +# the key prefix and swapping the picker catalog + aux model to routers. Kept +# here (commented out) so the wiring is ready when we turn it on. Router IDs and +# behavior track fw-ai/fireconnect setup-cli. +# +# _MANAGED_KEY_PREFIX = "fpk_" +# _MANAGED_ROUTERS = ( +# "accounts/fireworks/routers/glm-latest", # default +# "accounts/fireworks/routers/glm-fast-latest", +# "accounts/fireworks/routers/kimi-latest", +# "accounts/fireworks/routers/kimi-k2p6-turbo", # turbo tier — not a default +# ) +# +# def _is_managed_key(api_key: str) -> bool: +# return bool(api_key) and api_key.startswith(_MANAGED_KEY_PREFIX) +# --------------------------------------------------------------------------- diff --git a/plugins/model-providers/fireworks/plugin.yaml b/plugins/model-providers/fireworks/plugin.yaml new file mode 100644 index 00000000000..1523792ce80 --- /dev/null +++ b/plugins/model-providers/fireworks/plugin.yaml @@ -0,0 +1,5 @@ +name: fireworks-provider +kind: model-provider +version: 1.0.0 +description: Fireworks AI — fast inference for open and proprietary models +author: Nous Research diff --git a/tests/hermes_cli/test_fireworks_provider.py b/tests/hermes_cli/test_fireworks_provider.py new file mode 100644 index 00000000000..7ea528dd6c7 --- /dev/null +++ b/tests/hermes_cli/test_fireworks_provider.py @@ -0,0 +1,203 @@ +"""Focused tests for Fireworks AI first-class provider wiring. + +Fireworks is the bundled, preferred BYOK provider (sits right after Nous +Portal). These tests pin the wiring that makes it a real provider — alias +resolution through BOTH CLI resolvers, ordering, config/doctor/overlay +registration, attribution headers, and credential/base-URL resolution — without +any live network calls. +""" + +from __future__ import annotations + +import contextlib +import io +import sys +import types +from argparse import Namespace + +import pytest + +if "dotenv" not in sys.modules: + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + sys.modules["dotenv"] = fake_dotenv + +from hermes_cli.auth import resolve_api_key_provider_credentials +from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS, normalize_provider + + +@pytest.fixture(autouse=True) +def _clear_provider_env(monkeypatch): + for key in ("FIREWORKS_API_KEY", "FIREWORKS_BASE_URL"): + monkeypatch.delenv(key, raising=False) + + +class TestFireworksAliases: + """Both CLI resolvers must map the aliases — the plugin's aliases= tuple is + NOT consulted by these static maps, so they need explicit coverage.""" + + @pytest.mark.parametrize("alias", ["fireworks", "fireworks-ai", "fw", "FW", " Fireworks-AI "]) + def test_models_normalize_provider(self, alias): + assert normalize_provider(alias) == "fireworks" + + @pytest.mark.parametrize("alias", ["fireworks", "fireworks-ai", "fw"]) + def test_providers_normalize_provider(self, alias): + from hermes_cli.providers import normalize_provider as normalize_in_providers + + assert normalize_in_providers(alias) == "fireworks" + + +class TestFireworksOrdering: + """Product contract: Fireworks is the preferred provider and sits directly + below Nous Portal (Nous's own provider stays first).""" + + def test_present_in_canonical_providers(self): + slugs = [p.slug for p in CANONICAL_PROVIDERS] + assert "fireworks" in slugs + + def test_sits_immediately_after_nous(self): + slugs = [p.slug for p in CANONICAL_PROVIDERS] + assert slugs.index("fireworks") == slugs.index("nous") + 1, slugs[:4] + + def test_ranks_ahead_of_the_other_byok_providers(self): + slugs = [p.slug for p in CANONICAL_PROVIDERS] + # Ahead of the generic aggregators / other direct providers. + assert slugs.index("fireworks") < slugs.index("openrouter") + + def test_has_a_label(self): + assert _PROVIDER_LABELS.get("fireworks") == "Fireworks AI" + + +class TestFireworksConfigRegistry: + def test_optional_env_vars_include_fireworks(self): + from hermes_cli.config import OPTIONAL_ENV_VARS + + assert "FIREWORKS_API_KEY" in OPTIONAL_ENV_VARS + assert OPTIONAL_ENV_VARS["FIREWORKS_API_KEY"]["category"] == "provider" + assert OPTIONAL_ENV_VARS["FIREWORKS_API_KEY"]["password"] is True + + assert "FIREWORKS_BASE_URL" in OPTIONAL_ENV_VARS + assert OPTIONAL_ENV_VARS["FIREWORKS_BASE_URL"]["password"] is False + + +class TestFireworksOverlay: + 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_override == "https://api.fireworks.ai/inference/v1" + assert overlay.base_url_env_var == "FIREWORKS_BASE_URL" + assert not overlay.is_aggregator + + +class TestFireworksDoctor: + def test_provider_env_hints_include_fireworks(self): + from hermes_cli.doctor import _PROVIDER_ENV_HINTS + + assert "FIREWORKS_API_KEY" in _PROVIDER_ENV_HINTS + + def test_slash_form_model_is_not_flagged_as_vendor_prefixed(self, monkeypatch, tmp_path): + """Fireworks' native model IDs are slash-form (accounts/fireworks/...), + so doctor must NOT warn that provider should be 'openrouter' / the prefix + dropped — that heuristic is for aggregator vendor slugs only.""" + from hermes_cli import doctor as doctor_mod + + home = tmp_path / ".hermes" + home.mkdir(parents=True) + (home / "config.yaml").write_text( + "model:\n" + " provider: fireworks\n" + " default: accounts/fireworks/models/kimi-k2p6\n" + "memory: {}\n", + encoding="utf-8", + ) + (home / ".env").write_text("FIREWORKS_API_KEY=fw_test\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir() + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setenv("FIREWORKS_API_KEY", "fw_test") + + # Keep the run offline and cheap. + import httpx + + monkeypatch.setattr(httpx, "get", lambda *a, **k: types.SimpleNamespace(status_code=200)) + monkeypatch.setitem( + sys.modules, + "model_tools", + types.SimpleNamespace(check_tool_availability=lambda *a, **k: ([], []), TOOLSET_REQUIREMENTS={}), + ) + with contextlib.suppress(Exception): + from hermes_cli import auth as _auth_mod + + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + + buf = io.StringIO() + with contextlib.suppress(SystemExit), contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "vendor-prefixed" not in out + assert "vendor/model slug" not in out + + +class TestFireworksCredentials: + def test_resolves_default_base_url(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw_test_key") + creds = resolve_api_key_provider_credentials("fireworks") + assert creds["api_key"] == "fw_test_key" + assert creds["base_url"] == "https://api.fireworks.ai/inference/v1" + + def test_base_url_override_is_honored(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw_test_key") + monkeypatch.setenv("FIREWORKS_BASE_URL", "https://gateway.example.com/v1") + creds = resolve_api_key_provider_credentials("fireworks") + assert creds["base_url"] == "https://gateway.example.com/v1" + + +class TestFireworksAuxiliary: + """resolve_provider_client wires the BYOK key, PAYG-safe aux model, and the + partner-attribution headers through to the OpenAI client.""" + + def _resolve(self, name): + from unittest.mock import patch + + from agent.auxiliary_client import resolve_provider_client + + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = object() + client, model = resolve_provider_client(name) + return client, model, mock_openai.call_args.kwargs + + def test_client_carries_attribution_headers(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw_test_key") + client, model, kwargs = self._resolve("fireworks") + assert client is not None + headers = kwargs.get("default_headers", {}) + assert headers.get("HTTP-Referer") == "https://hermes-agent.nousresearch.com" + assert headers.get("X-Title") == "Hermes Agent" + assert kwargs["base_url"] == "https://api.fireworks.ai/inference/v1" + + def test_aux_model_is_payg_safe(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw_test_key") + _, model, _ = self._resolve("fireworks") + assert model.startswith("accounts/fireworks/models/") + assert "/routers/" not in model + assert "turbo" not in model.lower() + + def test_alias_resolves_through_aux_client(self, monkeypatch): + monkeypatch.setenv("FIREWORKS_API_KEY", "fw_test_key") + client, _, _ = self._resolve("fw") + assert client is not None + + +class TestFireworksModelMetadata: + def test_url_infers_fireworks(self): + from agent.model_metadata import _infer_provider_from_url + + assert _infer_provider_from_url("https://api.fireworks.ai/inference/v1") == "fireworks" diff --git a/tests/plugins/model_providers/test_fireworks_profile.py b/tests/plugins/model_providers/test_fireworks_profile.py new file mode 100644 index 00000000000..c44ecbb79b1 --- /dev/null +++ b/tests/plugins/model_providers/test_fireworks_profile.py @@ -0,0 +1,85 @@ +"""Unit tests for the Fireworks AI provider profile. + +Pins the profile's contract without going live: identity, attribution headers +(so Fireworks can attribute Hermes traffic for the partnership), alias +registration, and the pay-as-you-go model defaults (direct catalog ``/models/`` +IDs, not the router-only tier). +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def fireworks_profile(): + """Resolve the registered Fireworks profile through the real discovery path.""" + # Importing model_tools triggers plugin discovery, registering the profile. + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("fireworks") + assert profile is not None, "fireworks provider profile must be registered" + return profile + + +class TestFireworksIdentity: + def test_core_fields(self, fireworks_profile): + p = fireworks_profile + assert p.name == "fireworks" + assert p.auth_type == "api_key" + assert p.base_url == "https://api.fireworks.ai/inference/v1" + assert "FIREWORKS_API_KEY" in p.env_vars + assert "FIREWORKS_BASE_URL" in p.env_vars + + def test_display_metadata_present(self, fireworks_profile): + # Prominence copy is surfaced in the picker; keep it non-empty rather + # than pinning exact marketing wording (that's expected to change). + assert fireworks_profile.display_name + assert fireworks_profile.description + assert fireworks_profile.signup_url.startswith("https://") + + +class TestFireworksAttributionHeaders: + """The profile carries the partner-attribution headers Fireworks captures.""" + + def test_referer_and_title_headers(self, fireworks_profile): + headers = fireworks_profile.default_headers + assert headers.get("HTTP-Referer") == "https://hermes-agent.nousresearch.com" + assert headers.get("X-Title") == "Hermes Agent" + + +class TestFireworksAliases: + @pytest.mark.parametrize("alias", ["fireworks-ai", "fw"]) + def test_alias_resolves_via_registry(self, fireworks_profile, alias): + import providers + + resolved = providers.get_provider_profile(alias) + assert resolved is not None + assert resolved.name == "fireworks" + + def test_aliases_declared_on_profile(self, fireworks_profile): + assert "fireworks-ai" in fireworks_profile.aliases + assert "fw" in fireworks_profile.aliases + + +class TestFireworksModelDefaults: + """Defaults must be usable with a standard pay-as-you-go key. + + PAYG keys address ``accounts/fireworks/models/...`` directly; the bundled + defaults target that (the BYOK motion) so a fresh key works out of the box, + and use the standard tier rather than turbo as the out-of-box default. + """ + + def test_aux_model_is_payg_model_not_router(self, fireworks_profile): + aux = fireworks_profile.default_aux_model + assert aux.startswith("accounts/fireworks/models/"), aux + assert "/routers/" not in aux + assert "turbo" not in aux.lower() + + def test_fallback_models_are_payg_models_not_routers(self, fireworks_profile): + assert fireworks_profile.fallback_models, "expected curated fallbacks" + for model in fireworks_profile.fallback_models: + assert model.startswith("accounts/fireworks/models/"), model + assert "/routers/" not in model + assert "turbo" not in model.lower(), model diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index fba5a02df11..ee62cccd5e4 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -68,7 +68,7 @@ def test_all_profiles_register(): # Spot-check representative providers from different categories for required in ( "openrouter", "anthropic", "custom", "bedrock", "openai-codex", - "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", + "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", "fireworks", ): assert required in names, f"Missing profile: {required}" diff --git a/tests/run_agent/test_fireworks_live.py b/tests/run_agent/test_fireworks_live.py new file mode 100644 index 00000000000..7f2abfd1de9 --- /dev/null +++ b/tests/run_agent/test_fireworks_live.py @@ -0,0 +1,66 @@ +"""Live Fireworks smoke test — exercises the Hermes runtime, not a raw SDK client. + +Opt-in only: + HERMES_LIVE_TESTS=1 FIREWORKS_API_KEY=fw_... \\ + pytest tests/run_agent/test_fireworks_live.py -q + +Unlike a bare OpenAI() client pointed at the endpoint, this drives Hermes' +own provider resolution — ``resolve_provider_client('fireworks')`` — so it +verifies the auth/config/base-URL/attribution-header/aux-model wiring that the +bundled provider actually ships, then makes a real call through that client. +""" + +from __future__ import annotations + +import os + +import pytest + +LIVE = os.environ.get("HERMES_LIVE_TESTS") == "1" +FIREWORKS_KEY = os.environ.get("FIREWORKS_API_KEY", "") + +pytestmark = [ + pytest.mark.skipif(not LIVE, reason="live-only: set HERMES_LIVE_TESTS=1"), + pytest.mark.skipif(not FIREWORKS_KEY, reason="FIREWORKS_API_KEY not configured"), + pytest.mark.integration, +] + + +def _resolve_runtime_client(provider="fireworks"): + """Build the Fireworks client the way the Hermes runtime does.""" + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client(provider) + assert client is not None, "Hermes failed to build a Fireworks client" + return client, model + + +def test_hermes_wires_fireworks_client(): + """The runtime resolves a Fireworks client pointed at the right endpoint + with the partner-attribution headers applied — no network required.""" + client, model = _resolve_runtime_client() + assert "api.fireworks.ai" in str(client.base_url) + # Default aux model must be a PAYG /models/ id (works with an fw_ key). + assert model.startswith("accounts/fireworks/models/") + + +def test_fireworks_basic_chat_through_runtime(): + """A single-turn completion via the Hermes-resolved client returns text.""" + client, model = _resolve_runtime_client() + + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": "Say exactly the word 'pong' and nothing else."}], + max_tokens=32, + temperature=0.0, + timeout=60, + ) + + content = response.choices[0].message.content + assert content and "pong" in content.lower() + + +def test_fireworks_alias_resolves_through_runtime(): + """The 'fw' alias resolves to the same Fireworks client via the runtime.""" + client, _ = _resolve_runtime_client("fw") + assert "api.fireworks.ai" in str(client.base_url)