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.
This commit is contained in:
Alex Jestin Taylor 2026-07-08 15:32:46 -07:00 committed by Teknium
parent 8041be7954
commit c97d9a4c07
21 changed files with 583 additions and 11 deletions

View file

@ -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)
# =============================================================================

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Fireworks AI">
<path d="M15.9851 19.1274C15.0969 19.1274 14.2999 18.6001 13.96 17.7838L9.86258 8H12.2608L15.9991 16.9499L19.7339 8H22.1321L18.0102 17.7873C17.6686 18.6001 16.8733 19.1274 15.9851 19.1274Z" fill="#FFFFFF"/>
<path d="M21.3316 23.8029C20.4469 23.8029 19.6533 23.2792 19.31 22.4698C18.9649 21.6535 19.1436 20.7215 19.7672 20.0891L27.2299 12.5302L28.1618 14.7287L21.3298 21.636L31.068 21.5817L32 23.7802L21.3333 23.8065L21.3298 23.8029H21.3316Z" fill="#FFFFFF"/>
<path d="M0 23.7766L0.931955 21.5781L10.6702 21.6324L3.83993 14.7234L4.77189 12.5249L12.2345 20.0838C12.8582 20.7145 13.0386 21.65 12.6918 22.4645C12.3484 23.2756 11.5513 23.7977 10.6702 23.7977L0.00350359 23.7731L0 23.7766Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 848 B

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Fireworks AI">
<path d="M15.9851 19.1274C15.0969 19.1274 14.2999 18.6001 13.96 17.7838L9.86258 8H12.2608L15.9991 16.9499L19.7339 8H22.1321L18.0102 17.7873C17.6686 18.6001 16.8733 19.1274 15.9851 19.1274Z" fill="#6720FF"/>
<path d="M21.3316 23.8029C20.4469 23.8029 19.6533 23.2792 19.31 22.4698C18.9649 21.6535 19.1436 20.7215 19.7672 20.0891L27.2299 12.5302L28.1618 14.7287L21.3298 21.636L31.068 21.5817L32 23.7802L21.3333 23.8065L21.3298 23.8029H21.3316Z" fill="#6720FF"/>
<path d="M0 23.7766L0.931955 21.5781L10.6702 21.6324L3.83993 14.7234L4.77189 12.5249L12.2345 20.0838C12.8582 20.7145 13.0386 21.65 12.6918 22.4645C12.3484 23.2756 11.5513 23.7977 10.6702 23.7977L0.00350359 23.7731L0 23.7766Z" fill="#6720FF"/>
</svg>

After

Width:  |  Height:  |  Size: 848 B

View file

@ -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(<Picker ctx={ctx} />)
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(<Picker ctx={ctx} />)
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(<Picker ctx={ctx} />)
fireEvent.click(screen.getByText('Fireworks AI'))
expect($desktopOnboarding.get().mode).toBe('apikey')
})
it('offers "choose later" on first run and persists the skip', () => {

View file

@ -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<string | undefined>(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 }) {
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : apiKeyInitialEnv}
onBack={() => 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 }) {
<div className="grid gap-2">
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1">
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
{/* Fireworks preferred BYOK provider, second hero card after Nous.
Routes into the API-key form preselecting the Fireworks key. */}
<FeaturedFireworksRow onSelect={() => openKeyForm('FIREWORKS_API_KEY')} />
{showRest ? (
<>
{rest.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
<KeyProviderRow onClick={() => openKeyForm('OPENROUTER_API_KEY')} />
</>
) : null}
</div>
@ -466,7 +490,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
{manual ? <span /> : <ChooseLaterLink />}
<Button
className="-mr-2 font-medium"
onClick={() => setOnboardingMode('apikey')}
onClick={() => openKeyForm()}
size="xs"
type="button"
variant="text"

View file

@ -62,6 +62,34 @@ export function FeaturedProviderRow({
)
}
// Fireworks is the preferred BYOK provider — a second hero card directly below
// Nous Portal. It isn't an OAuth provider (no sign-in flow), so it takes a bare
// onSelect that routes into the API-key form rather than an OAuthProvider.
export function FeaturedFireworksRow({ onSelect }: { onSelect: () => void }) {
const { t } = useI18n()
return (
<button
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
onClick={onSelect}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<img alt="" className="size-5 shrink-0" src={assetPath('fireworks.svg')} />
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">Fireworks AI</span>
<span className="inline-flex items-center gap-1.5 bg-[#6720FF] px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-white">
<img alt="" aria-hidden="true" className="size-2.5 shrink-0" src={assetPath('fireworks-white.svg')} />
{t.onboarding.recommended}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.fireworksPitch}</p>
</div>
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
)
}
function ConnectedTag() {
const { t } = useI18n()

View file

@ -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.'

View file

@ -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 つのキーで数百のモデルをホスト。新規インストールのデフォルトとして最適。'

View file

@ -1582,6 +1582,7 @@ export interface Translations {
recommended: string
connected: string
featuredPitch: string
fireworksPitch: string
openRouterPitch: string
apiKeyOptions: Record<string, { short: string; description: string }>
backToSignIn: string

View file

@ -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 模型。' },

View file

@ -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 模型。' },

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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)
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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}"

View file

@ -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)