mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
parent
8041be7954
commit
c97d9a4c07
21 changed files with 583 additions and 11 deletions
10
.env.example
10
.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)
|
||||
# =============================================================================
|
||||
|
|
|
|||
5
apps/desktop/public/fireworks-white.svg
Normal file
5
apps/desktop/public/fireworks-white.svg
Normal 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 |
5
apps/desktop/public/fireworks.svg
Normal file
5
apps/desktop/public/fireworks.svg
Normal 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 |
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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 つのキーで数百のモデルをホスト。新規インストールのデフォルトとして最適。'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 模型。' },
|
||||
|
|
|
|||
|
|
@ -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 模型。' },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
68
plugins/model-providers/fireworks/__init__.py
Normal file
68
plugins/model-providers/fireworks/__init__.py
Normal 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
5
plugins/model-providers/fireworks/plugin.yaml
Normal file
5
plugins/model-providers/fireworks/plugin.yaml
Normal 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
|
||||
203
tests/hermes_cli/test_fireworks_provider.py
Normal file
203
tests/hermes_cli/test_fireworks_provider.py
Normal 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"
|
||||
85
tests/plugins/model_providers/test_fireworks_profile.py
Normal file
85
tests/plugins/model_providers/test_fireworks_profile.py
Normal 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
|
||||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
66
tests/run_agent/test_fireworks_live.py
Normal file
66
tests/run_agent/test_fireworks_live.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue