Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions app/(dashboard)/harnesses/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { RiskBar } from '@/components/shared/risk-bar'
import { TierMix } from '@/components/shared/tier-mix'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { Harness, HabitatTier, Tool, Key, MemoryScope, Surface } from '@/lib/types'
import { providerOptions } from '@/lib/key-providers'
import { SignalSetupDialog } from '@/components/surfaces/signal-setup-dialog'
import { TelegramSetupDialog } from '@/components/surfaces/telegram-setup-dialog'
import { MattermostSetupDialog } from '@/components/surfaces/mattermost-setup-dialog'
Expand Down Expand Up @@ -73,13 +74,6 @@ const SURFACE_STATUS_STYLES: Record<Surface['status'], string> = {

const MODEL_PROVIDERS = ['anthropic', 'openrouter', 'ollama', 'custom', 'gemini', 'nous', 'bedrock', 'zai'] as const

const KEY_PROVIDERS = [
'anthropic', 'openai', 'openrouter', 'zai', 'notion', 'github', 'telegram', 'signal',
'mattermost', 'aws', 'aws-bedrock', 'google-cloud', 'brave',
'helius', 'coingecko', 'dehashed', 'opencorporates', 'capsolver',
'open-measures', 'pexels',
]

type FallbackProviderEntry = { provider: string; model: string; base_url?: string }

type ModelConfig = { provider: string; primary: string; models: string[]; fallbackProviders?: FallbackProviderEntry[] }
Expand Down Expand Up @@ -193,6 +187,7 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
const [newKeyName, setNewKeyName] = useState('')
const [newKeyValue, setNewKeyValue] = useState('')
const [newKeyBudget, setNewKeyBudget] = useState('')
const [newKeyEnvVar, setNewKeyEnvVar] = useState('')
const [keySaving, setKeySaving] = useState(false)
const [showAssignKey, setShowAssignKey] = useState(false)

Expand Down Expand Up @@ -238,6 +233,9 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
value: newKeyValue,
assignedTo: [harness.id],
...(newKeyName ? { name: newKeyName } : {}),
// Only meaningful for custom/unknown providers — resolveEnvVar ignores
// the hint for mapped ones, but don't send a stale value regardless.
...(newKeyEnvVar && newKeyProvider === 'custom' ? { envVar: newKeyEnvVar } : {}),
...(newKeyBudget ? { budgetUsd: parseFloat(newKeyBudget) } : {}),
}),
})
Expand All @@ -252,6 +250,7 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
setNewKeyName('')
setNewKeyValue('')
setNewKeyBudget('')
setNewKeyEnvVar('')
// Recreate (not 'quick') so the new env_file value actually loads — a plain
// restart keeps the old creation-time env. (POST /api/keys also recreates
// server-side; this is the belt-and-suspenders client trigger.)
Expand Down Expand Up @@ -1285,11 +1284,14 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
<label className="text-xs text-muted-foreground">Provider</label>
<select
value={newKeyProvider}
onChange={(e) => setNewKeyProvider(e.target.value)}
onChange={(e) => {
setNewKeyProvider(e.target.value)
if (e.target.value !== 'custom') setNewKeyEnvVar('')
}}
className="w-full mt-1 text-sm border border-[var(--border)] rounded-md px-2 py-1.5 bg-[var(--surface)]"
>
<option value="">Select provider...</option>
{KEY_PROVIDERS.map((p) => (
{providerOptions(newKeyProvider).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
Expand All @@ -1315,6 +1317,18 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
className="w-full mt-1 text-sm border border-[var(--border)] rounded-md px-2 py-1.5 bg-[var(--surface)]"
/>
</div>
{newKeyProvider === 'custom' && (
<div>
<label className="text-xs text-muted-foreground">Env var (optional)</label>
<input
type="text"
value={newKeyEnvVar}
onChange={(e) => setNewKeyEnvVar(e.target.value)}
placeholder="e.g. SANTIMENT_API_KEY — else derived from name"
className="w-full mt-1 text-sm font-mono border border-[var(--border)] rounded-md px-2 py-1.5 bg-[var(--surface)]"
/>
</div>
)}
<div>
<label className="text-xs text-muted-foreground">API Key</label>
<input
Expand All @@ -1337,7 +1351,7 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
<Button
variant="ghost"
size="sm"
onClick={() => { setShowAddKey(false); setNewKeyProvider(''); setNewKeyName(''); setNewKeyValue(''); setNewKeyBudget('') }}
onClick={() => { setShowAddKey(false); setNewKeyProvider(''); setNewKeyName(''); setNewKeyValue(''); setNewKeyBudget(''); setNewKeyEnvVar('') }}
>
Cancel
</Button>
Expand Down
14 changes: 2 additions & 12 deletions app/(dashboard)/keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,10 @@ import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { filterKeys } from '@/lib/keys-filter'
import { parseKeyRequestParams } from '@/lib/keys-request'
import { providerOptions } from '@/lib/key-providers'
import type { Key, Harness } from '@/lib/types'
import type { HabitatTier, HarnessStatus } from '@/lib/types'

const KEY_PROVIDERS = [
// Model/inference providers first. `openrouter` maps to OPENROUTER_API_KEY on
// the write path (see KeysService.resolveEnvVar) — the fleet's GLM-5.2 chat
// primary AND its cheap-metered rung ([intelligent-routing-cost]) both ride
// OpenRouter, so neither can be provisioned without it here.
'anthropic', 'openai', 'google', 'openrouter', 'aws', 'github', 'brave', 'notion', 'telegram', 'custom',
]

function keyHealthToStatus(health: Key['health']): HarnessStatus {
if (health === 'good') return 'running'
if (health === 'warning') return 'idle'
Expand Down Expand Up @@ -256,10 +249,7 @@ function KeysPageContent() {
className="w-full rounded-md border border-[var(--border)] bg-background px-3 py-1.5 text-sm"
>
<option value="">Select provider...</option>
{(newProvider && !KEY_PROVIDERS.includes(newProvider)
? [...KEY_PROVIDERS, newProvider]
: KEY_PROVIDERS
).map((p) => (
{providerOptions(newProvider).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
Expand Down
68 changes: 68 additions & 0 deletions lib/__tests__/key-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { KEY_PROVIDERS, providerOptions } from '@/lib/key-providers'

const repoRoot = join(__dirname, '..', '..')
const read = (p: string) => readFileSync(join(repoRoot, p), 'utf8')

const KEYS_PAGE = 'app/(dashboard)/keys/page.tsx'
const HARNESS_PAGE = 'app/(dashboard)/harnesses/[id]/page.tsx'

describe('KEY_PROVIDERS', () => {
it('has no duplicates', () => {
expect(new Set(KEY_PROVIDERS).size).toBe(KEY_PROVIDERS.length)
})

it('offers custom — the escape hatch both add-key menus must expose', () => {
expect(KEY_PROVIDERS).toContain('custom')
})

it('retains every provider each menu had before they were unified', () => {
// Regression guard for the drift this module fixed: the harness-scoped menu
// and the global keys menu each carried providers the other lacked.
const onceGlobalOnly = ['google', 'custom']
const onceHarnessOnly = [
'zai', 'signal', 'mattermost', 'aws-bedrock', 'google-cloud', 'helius',
'coingecko', 'dehashed', 'opencorporates', 'capsolver', 'open-measures', 'pexels',
]
for (const p of [...onceGlobalOnly, ...onceHarnessOnly]) {
expect(KEY_PROVIDERS, `${p} went missing`).toContain(p)
}
})
})

describe('providerOptions', () => {
it('returns the canonical list for a known or empty provider', () => {
expect(providerOptions('')).toEqual(KEY_PROVIDERS)
expect(providerOptions('anthropic')).toEqual(KEY_PROVIDERS)
})

it('appends an unknown provider so a prefill is never silently dropped', () => {
const opts = providerOptions('hedra')
expect(opts).toContain('hedra')
expect(opts).toHaveLength(KEY_PROVIDERS.length + 1)
})
})

describe('both add-key menus stay in parity', () => {
// The whole point of lib/key-providers: a locally-redeclared list is how the
// two menus diverged in the first place. Fail loudly if one reappears.
it.each([KEYS_PAGE, HARNESS_PAGE])('%s declares no local provider list', (page) => {
expect(read(page)).not.toMatch(/const\s+KEY_PROVIDERS\s*=/)
})

it.each([KEYS_PAGE, HARNESS_PAGE])('%s sources its options from the shared module', (page) => {
const src = read(page)
expect(src).toContain("from '@/lib/key-providers'")
expect(src).toMatch(/providerOptions\(/)
})

it.each([KEYS_PAGE, HARNESS_PAGE])('%s renders the env-var field custom keys need', (page) => {
// Without it a custom key falls back to the name-derived var, or the
// useless CUSTOM_API_KEY — see KeysService.resolveEnvVar.
const src = read(page)
expect(src).toMatch(/===\s*'custom'\s*&&/)
expect(src).toMatch(/envVar/)
})
})
42 changes: 42 additions & 0 deletions lib/key-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Single source of truth for the "add an API key" provider dropdown.
//
// This list used to be duplicated: one copy in app/(dashboard)/keys/page.tsx and
// a second, unrelated copy in app/(dashboard)/harnesses/[id]/page.tsx. They drifted
// in BOTH directions — the harness-scoped menu was missing `google` and `custom`,
// the global menu was missing the twelve service providers below `brave`. Anything
// added to only one menu is invisible from the other, so keep this the only copy.
//
// Not to be confused with MODEL_PROVIDERS in harnesses/[id]/page.tsx — that drives
// the model cascade editor (inference backends), not credential storage.
//
// Ordering is presentational: model/inference first, then platform/surface, then
// service APIs, with `custom` last as the escape hatch.
//
// `custom` is special on the write path: KeysService.resolveEnvVar has no
// PROVIDER_TO_VAR entry for it, so it resolves via the explicit `envVar` hint,
// else the key's name, else the useless CUSTOM_API_KEY fallback. Any form that
// offers `custom` must therefore also offer the env-var field.
export const KEY_PROVIDERS = [
// Model / inference. `openrouter` maps to OPENROUTER_API_KEY on the write path
// (see KeysService.resolveEnvVar) — the fleet's GLM-5.2 chat primary AND its
// cheap-metered rung ([intelligent-routing-cost]) both ride OpenRouter, so
// neither can be provisioned without it here.
'anthropic', 'openai', 'google', 'google-cloud', 'openrouter', 'zai',
'aws', 'aws-bedrock',
// Platforms / surfaces
'github', 'notion', 'telegram', 'signal', 'mattermost',
// Service APIs
'brave', 'helius', 'coingecko', 'dehashed', 'opencorporates',
'capsolver', 'open-measures', 'pexels',
// Escape hatch — requires the env-var field, see above.
'custom',
]

// A provider may reach a form from outside the list (a ?request= prefill via
// lib/keys-request, or a key discovered from an existing .env). Appending it
// keeps the select from silently dropping the caller's choice.
export function providerOptions(current: string): string[] {
return current && !KEY_PROVIDERS.includes(current)
? [...KEY_PROVIDERS, current]
: KEY_PROVIDERS
}