diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx
index 27c029b442c..d20f71b5ab4 100644
--- a/apps/desktop/src/app/settings/providers-settings.test.tsx
+++ b/apps/desktop/src/app/settings/providers-settings.test.tsx
@@ -97,4 +97,31 @@ describe('ProvidersSettings', () => {
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
})
+
+ it('renders a Keys card for a backend-tagged provider with no PROVIDER_GROUPS prefix', async () => {
+ // A provider the backend catalog tags (provider/provider_label) but that has
+ // no desktop PROVIDER_GROUPS prefix row must still render its own card —
+ // this is the GUI/CLI drift fix: membership comes from the backend, not
+ // from the hand-maintained prefix list.
+ getEnvVars.mockResolvedValue({
+ WIDGETAI_API_KEY: {
+ advanced: false,
+ category: 'provider',
+ description: 'WidgetAI direct API',
+ is_password: true,
+ is_set: false,
+ provider: 'widgetai',
+ provider_label: 'WidgetAI',
+ redacted_value: null,
+ tools: [],
+ url: 'https://widgetai.example/keys'
+ }
+ })
+ listOAuthProviders.mockResolvedValue({ providers: [] })
+
+ const { ProvidersSettings } = await import('./providers-settings')
+ render()
+
+ expect(await screen.findByText('WidgetAI')).toBeTruthy()
+ })
})
diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx
index 2585e13995d..e0ae46e7da1 100644
--- a/apps/desktop/src/app/settings/providers-settings.tsx
+++ b/apps/desktop/src/app/settings/providers-settings.tsx
@@ -45,8 +45,17 @@ export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
// Group the env catalog by provider — one ListRow per vendor plus optional
-// advanced overrides (base URL, region, etc.). Groups without a key field and
-// the "Other" bucket are skipped.
+// advanced overrides (base URL, region, etc.). Groups without a key field are
+// skipped.
+//
+// Grouping key precedence:
+// 1. Backend `provider_label` / `provider` (from the unified provider catalog
+// in hermes_cli/provider_catalog.py) — the SAME provider identity
+// `hermes model` uses. This is authoritative: a provider tagged by the
+// backend always renders a card, even with no PROVIDER_GROUPS row.
+// 2. Desktop prefix match (`providerGroup`) — legacy fallback for provider
+// env vars that predate the backend tagging.
+// Only entries that resolve to neither (the "Other" bucket) are skipped.
function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] {
const buckets = new Map()
@@ -55,7 +64,9 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr
continue
}
- const name = providerGroup(key)
+ // Prefer the backend-supplied provider label/id so the Keys tab groups by
+ // the same identity the CLI picker uses; fall back to the prefix guess.
+ const name = info.provider_label?.trim() || info.provider?.trim() || providerGroup(key)
if (name === 'Other') {
continue
@@ -73,6 +84,9 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr
continue
}
+ // Presentation overlay (priority, blurb, docs) is keyed by the prefix-based
+ // group name; when the backend introduced this provider it may have no
+ // overlay entry, so fall back to the backend/env metadata for display.
const meta = providerMeta(name)
groups.push({
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index a497e3f10a9..b67cc3041a7 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -108,6 +108,12 @@ export interface EnvVarInfo {
description: string
is_password: boolean
is_set: boolean
+ // Backend-derived provider grouping hints (from the unified provider catalog
+ // in hermes_cli/provider_catalog.py). When present, the Keys tab groups by
+ // this provider identity — the SAME one `hermes model` uses — instead of
+ // desktop-only env-var prefix guesses. Empty for non-provider env vars.
+ provider?: string
+ provider_label?: string
redacted_value: null | string
tools: string[]
url: null | string