From 024b3d969f6b15d0792cb50c2336f8983684f6cc Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:04:20 +0000 Subject: [PATCH 01/19] fix(frontend): list provider integrations in settings --- backend/src/routes/oauth.test.ts | 35 ++++++ backend/src/routes/oauth.ts | 9 ++ frontend/src/api/oauth.ts | 10 +- .../settings/ProviderSettings.test.tsx | 68 +++++++++-- .../components/settings/ProviderSettings.tsx | 114 ++++++++++++------ frontend/src/contexts/EventContext.test.tsx | 3 +- frontend/src/hooks/useProviderIntegrations.ts | 14 +++ frontend/src/lib/queryInvalidation.test.ts | 3 +- frontend/src/lib/queryInvalidation.ts | 1 + 9 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 frontend/src/hooks/useProviderIntegrations.ts diff --git a/backend/src/routes/oauth.test.ts b/backend/src/routes/oauth.test.ts index ed2ceefdc..78da0a079 100644 --- a/backend/src/routes/oauth.test.ts +++ b/backend/src/routes/oauth.test.ts @@ -126,6 +126,41 @@ describe('oauth routes /auth-methods', () => { }) }) +describe('oauth routes /integrations', () => { + it('returns every integration with its methods and connections', async () => { + const client = createStubOpenCodeClient() + const integrations = [ + integrationFixture({ + id: 'openrouter', + name: 'OpenRouter', + methods: [{ type: 'key' }], + }), + integrationFixture({ + id: 'anthropic', + name: 'Anthropic', + methods: [{ type: 'key' }, { type: 'env', names: ['ANTHROPIC_API_KEY'] }], + connections: [{ type: 'credential', id: 'cred_1', label: 'Anthropic', method: 'key' }], + }), + ] + vi.mocked(client.api.integration.list).mockResolvedValueOnce({ location: LOCATION, data: integrations }) + + const res = await createOAuthApp(client).request('/oauth/integrations') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ integrations }) + }) + + it('returns 502 when the upstream is unreachable', async () => { + const client = createStubOpenCodeClient() + vi.mocked(client.api.integration.list).mockRejectedValueOnce(new ClientError('UnexpectedStatus')) + + const res = await createOAuthApp(client).request('/oauth/integrations') + + expect(res.status).toBe(502) + expect(await res.json()).toEqual({ error: 'Failed to list provider integrations', code: 'ClientError' }) + }) +}) + describe('oauth routes /:id/oauth/authorize', () => { it('starts the V2 attempt with the method and answer and returns it', async () => { const client = createStubOpenCodeClient() diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 9af60b6a9..6a6bf5e7f 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -23,6 +23,15 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient) { } }) + app.get('/integrations', async (c) => { + try { + const integrations = await openCodeClient.api.integration.list() + return c.json({ integrations: integrations.data }) + } catch (error) { + return handleOpenCodeError(c, error, 'Failed to list provider integrations') + } + }) + app.post('/:id/oauth/authorize', async (c) => { try { const body = await c.req.json() diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index 5546b79b4..a4f753061 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/config" -import type { FormAnswer, IntegrationMethod } from "@opencode-manager/shared/opencode" +import type { FormAnswer, IntegrationInfo, IntegrationMethod } from "@opencode-manager/shared/opencode" import type { OAuthAttemptStatus, OAuthAuthorizeResponse } from "@opencode-manager/shared/schemas" import { fetchWrapper, fetchWrapperVoid } from "./fetchWrapper" @@ -7,6 +7,7 @@ export type { FormAnswer, FormField, FormValue, + IntegrationInfo, IntegrationKeyMethod, IntegrationMethod, IntegrationOAuthMethod, @@ -45,4 +46,11 @@ export const oauthApi = { ) return providers }, + + getIntegrations: async (): Promise => { + const { integrations } = await fetchWrapper<{ integrations: IntegrationInfo[] }>( + `${API_BASE_URL}/api/oauth/integrations` + ) + return integrations + }, } diff --git a/frontend/src/components/settings/ProviderSettings.test.tsx b/frontend/src/components/settings/ProviderSettings.test.tsx index a922cda90..10dae4848 100644 --- a/frontend/src/components/settings/ProviderSettings.test.tsx +++ b/frontend/src/components/settings/ProviderSettings.test.tsx @@ -6,7 +6,7 @@ import { ProviderSettings } from './ProviderSettings' import { getProviders, providerCredentialsApi } from '@/api/providers' import { oauthApi } from '@/api/oauth' import type { Provider } from '@/api/providers' -import type { OAuthAuthorizeResponse } from '@/api/oauth' +import type { IntegrationInfo, IntegrationMethod, OAuthAuthorizeResponse } from '@/api/oauth' import type { ReactNode } from 'react' vi.mock('@/api/providers', () => ({ @@ -22,6 +22,7 @@ vi.mock('@/api/providers', () => ({ vi.mock('@/api/oauth', () => ({ oauthApi: { getAuthMethods: vi.fn(), + getIntegrations: vi.fn(), authorize: vi.fn(), callback: vi.fn(), }, @@ -102,6 +103,25 @@ const oauthProviderWithoutKey = providerFixture('google', 'Google', 3) const apiKeyProviderWithKey = providerFixture('openai', 'OpenAI', 1) const apiKeyProviderWithoutKey = providerFixture('together', 'Together AI', 4) +const oauthMethod: IntegrationMethod = { id: 'oauth', type: 'oauth', label: 'OAuth' } +const keyMethod: IntegrationMethod = { type: 'key', label: 'API key' } + +function integrationFixture( + id: string, + name: string, + methods: IntegrationMethod[], + connected = false, +): IntegrationInfo { + return { + id, + name, + methods, + connections: connected + ? [{ type: 'credential', id: `cred_${id}`, label: name, method: 'key' }] + : [], + } +} + function mockProviderData() { vi.mocked(getProviders).mockResolvedValue({ providers: [ @@ -112,11 +132,12 @@ function mockProviderData() { ], models: [], }) - vi.mocked(providerCredentialsApi.list).mockResolvedValue(['anthropic', 'openai']) - vi.mocked(oauthApi.getAuthMethods).mockResolvedValue({ - anthropic: [{ id: 'oauth', type: 'oauth', label: 'OAuth' }], - google: [{ id: 'oauth', type: 'oauth', label: 'OAuth' }], - }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('anthropic', 'Anthropic', [oauthMethod], true), + integrationFixture('google', 'Google', [oauthMethod]), + integrationFixture('openai', 'OpenAI', [keyMethod], true), + integrationFixture('together', 'Together AI', [keyMethod]), + ]) } function renderSettings() { @@ -270,11 +291,44 @@ describe('ProviderSettings', () => { }) it('shows the empty state when no OAuth-capable providers exist', async () => { - vi.mocked(oauthApi.getAuthMethods).mockResolvedValue({}) + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('openai', 'OpenAI', [keyMethod]), + integrationFixture('together', 'Together AI', [keyMethod]), + ]) renderSettings() await screen.findByText('No OAuth-capable providers available.') expect(screen.queryByText('Anthropic')).not.toBeInTheDocument() }) + + it('lists an unconfigured provider when the active catalog has no providers', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('openrouter', 'OpenRouter', [keyMethod]), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('OpenRouter') + expect(screen.getByRole('button', { name: /Available Providers/ })).toHaveTextContent('1') + + await user.type(screen.getByPlaceholderText('Search providers...'), 'openrouter') + expect(screen.getByText('OpenRouter')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Add Key' })) + expect(screen.getByTestId('api-key-dialog')).toHaveTextContent('OpenRouter') + }) + + it('lists OAuth integrations that are not in the active catalog', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('github-copilot', 'GitHub Copilot', [oauthMethod]), + ]) + renderSettings() + + await screen.findByText('GitHub Copilot') + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index cdcabb6da..ccf6aa1a5 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -7,14 +7,64 @@ import { DeleteDialog } from '@/components/ui/delete-dialog' import { Loader2, Check, X, Shield, ChevronDown, ChevronRight, Key, Search, Pencil, Trash2 } from 'lucide-react' import { providerCredentialsApi } from '@/api/providers' import type { Provider } from '@/api/providers' -import { type OAuthAuthorizeResponse } from '@/api/oauth' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { type IntegrationInfo, type IntegrationMethod, type OAuthAuthorizeResponse } from '@/api/oauth' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { OAuthAuthorizeDialog } from './OAuthAuthorizeDialog' import { OAuthCallbackDialog } from './OAuthCallbackDialog' import { ApiKeyDialog } from '@/components/model/ApiKeyDialog' import { invalidateProviderCaches } from '@/lib/queryInvalidation' import { useProviders } from '@/hooks/useProviders' -import { useProviderAuthMethods } from '@/hooks/useProviderAuthMethods' +import { useProviderIntegrations } from '@/hooks/useProviderIntegrations' + +interface ProviderEntry { + id: string + name: string + models: Provider['models'] + methods: IntegrationMethod[] + connected: boolean +} + +function hasCredentialConnection(integration: IntegrationInfo): boolean { + return integration.connections.some((connection) => connection.type === 'credential') +} + +function supportsOAuthMethods(methods: IntegrationMethod[]): boolean { + return methods.some((method) => method.type === 'oauth') +} + +function buildProviderEntries( + providers: Provider[] | undefined, + integrations: IntegrationInfo[] | undefined, +): ProviderEntry[] { + const integrationsByID = new Map((integrations ?? []).map((integration) => [integration.id, integration])) + const seen = new Set() + const entries: ProviderEntry[] = [] + + for (const provider of providers ?? []) { + const integration = integrationsByID.get(provider.id) + seen.add(provider.id) + entries.push({ + id: provider.id, + name: integration?.name || provider.name || provider.id, + models: provider.models, + methods: integration?.methods ?? [], + connected: integration ? hasCredentialConnection(integration) : false, + }) + } + + const integrationOnly = (integrations ?? []) + .filter((integration) => !seen.has(integration.id) && integration.methods.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((integration): ProviderEntry => ({ + id: integration.id, + name: integration.name || integration.id, + models: [], + methods: integration.methods, + connected: hasCredentialConnection(integration), + })) + + return [...entries, ...integrationOnly] +} export function ProviderSettings() { const [selectedProvider, setSelectedProvider] = useState(null) @@ -34,12 +84,12 @@ export function ProviderSettings() { const providers = providersData?.providers - const { data: credentialsList, isLoading: credentialsLoading } = useQuery({ - queryKey: ['provider-credentials'], - queryFn: () => providerCredentialsApi.list(), - }) + const { data: integrations, isLoading: integrationsLoading } = useProviderIntegrations() - const { data: authMethods } = useProviderAuthMethods() + const providerEntries = useMemo( + () => buildProviderEntries(providers, integrations), + [providers, integrations], + ) const deleteCredentialMutation = useMutation({ mutationFn: (providerId: string) => providerCredentialsApi.delete(providerId), @@ -81,32 +131,19 @@ export function ProviderSettings() { setSelectedProvider(null) } - const supportsOAuth = useCallback((providerId: string) => { - const methods = authMethods?.[providerId] || [] - return methods.some(method => method.type === 'oauth') - }, [authMethods]) - - const hasCredentials = useCallback((providerId: string) => { - return credentialsList?.includes(providerId) || false - }, [credentialsList]) - const oauthProviders = useMemo(() => { - if (!providers || !authMethods) return [] - const oauth = providers.filter(provider => supportsOAuth(provider.id)) - return oauth.slice().sort((a, b) => { - const aConnected = hasCredentials(a.id) ? 1 : 0 - const bConnected = hasCredentials(b.id) ? 1 : 0 - return bConnected - aConnected - }) - }, [providers, authMethods, supportsOAuth, hasCredentials]) + return providerEntries + .filter((provider) => supportsOAuthMethods(provider.methods)) + .sort((a, b) => Number(b.connected) - Number(a.connected)) + }, [providerEntries]) const apiKeyProviders = useMemo(() => { - if (!providers || !authMethods) return { connected: [], available: [] } - const nonOAuthProviders = providers.filter(provider => !supportsOAuth(provider.id)) - const connected = nonOAuthProviders.filter(provider => hasCredentials(provider.id)) - const available = nonOAuthProviders.filter(provider => !hasCredentials(provider.id)) - return { connected, available } - }, [providers, authMethods, supportsOAuth, hasCredentials]) + const nonOAuthProviders = providerEntries.filter((provider) => !supportsOAuthMethods(provider.methods)) + return { + connected: nonOAuthProviders.filter((provider) => provider.connected), + available: nonOAuthProviders.filter((provider) => !provider.connected), + } + }, [providerEntries]) const filteredAvailableProviders = useMemo(() => { if (!availableSearch.trim()) return apiKeyProviders.available @@ -119,8 +156,13 @@ export function ProviderSettings() { const selectedProviderName = useMemo(() => { if (!selectedProvider) return '' - return providers?.find(p => p.id === selectedProvider)?.name || selectedProvider - }, [selectedProvider, providers]) + return providerEntries.find((provider) => provider.id === selectedProvider)?.name || selectedProvider + }, [selectedProvider, providerEntries]) + + const selectedProviderMethods = useMemo(() => { + if (!selectedProvider) return [] + return providerEntries.find((provider) => provider.id === selectedProvider)?.methods ?? [] + }, [selectedProvider, providerEntries]) const handleAddApiKey = useCallback((provider: Provider) => { setApiKeyProvider(provider) @@ -147,7 +189,7 @@ export function ProviderSettings() { } }, []) - if (providersLoading || credentialsLoading) { + if (providersLoading || integrationsLoading) { return (
@@ -177,7 +219,7 @@ export function ProviderSettings() { ) : (
{oauthProviders.map((provider) => { - const hasKey = hasCredentials(provider.id) + const hasKey = provider.connected const modelCount = provider.models.length return ( @@ -236,7 +278,7 @@ export function ProviderSettings() { { for (const queryKey of [ ['provider-credentials'], ['provider-auth-methods'], + ['provider-integrations'], ['opencode', 'providers'], ]) { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) @@ -1149,7 +1150,7 @@ describe('EventProvider permissions and forms', () => { await waitFor(() => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'providers'] }) }) - expect(invalidateQueries).toHaveBeenCalledTimes(3) + expect(invalidateQueries).toHaveBeenCalledTimes(4) }) it.each<[string, string[]]>([ diff --git a/frontend/src/hooks/useProviderIntegrations.ts b/frontend/src/hooks/useProviderIntegrations.ts new file mode 100644 index 000000000..668f0d907 --- /dev/null +++ b/frontend/src/hooks/useProviderIntegrations.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { oauthApi } from '@/api/oauth' + +interface UseProviderIntegrationsOptions { + enabled?: boolean +} + +export function useProviderIntegrations(options: UseProviderIntegrationsOptions = {}) { + return useQuery({ + queryKey: ['provider-integrations'], + queryFn: () => oauthApi.getIntegrations(), + enabled: options.enabled ?? true, + }) +} diff --git a/frontend/src/lib/queryInvalidation.test.ts b/frontend/src/lib/queryInvalidation.test.ts index 2ef7c355a..fdf78be74 100644 --- a/frontend/src/lib/queryInvalidation.test.ts +++ b/frontend/src/lib/queryInvalidation.test.ts @@ -132,6 +132,7 @@ describe('invalidateProviderCaches', () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['provider-credentials'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['provider-auth-methods'] }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['provider-integrations'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'providers'] }) expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['providers'] }) expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['providers-for-execution-model'] }) @@ -156,7 +157,7 @@ describe('invalidateProviderCachesDebounced', () => { vi.advanceTimersByTime(200) - expect(invalidateQueries).toHaveBeenCalledTimes(3) + expect(invalidateQueries).toHaveBeenCalledTimes(4) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'providers'] }) }) }) diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index dbdc51468..636c1caf5 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -62,6 +62,7 @@ export function invalidateChildSessionCaches(queryClient: QueryClient, sessionID export function invalidateProviderCaches(queryClient: QueryClient) { queryClient.invalidateQueries({ queryKey: ['provider-credentials'] }) queryClient.invalidateQueries({ queryKey: ['provider-auth-methods'] }) + queryClient.invalidateQueries({ queryKey: ['provider-integrations'] }) queryClient.invalidateQueries({ queryKey: ['opencode', 'providers'] }) } From ee1a39b5f0d9008f8e4eceb3e29dc2836c527638 Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:55:04 -0500 Subject: [PATCH 02/19] fix(frontend): handle provider integration errors --- .../components/settings/ProviderSettings.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index ccf6aa1a5..30dc56650 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -32,6 +32,10 @@ function supportsOAuthMethods(methods: IntegrationMethod[]): boolean { return methods.some((method) => method.type === 'oauth') } +function supportsSettingsAuthentication(methods: IntegrationMethod[]): boolean { + return methods.some((method) => method.type === 'oauth' || method.type === 'key') +} + function buildProviderEntries( providers: Provider[] | undefined, integrations: IntegrationInfo[] | undefined, @@ -53,7 +57,7 @@ function buildProviderEntries( } const integrationOnly = (integrations ?? []) - .filter((integration) => !seen.has(integration.id) && integration.methods.length > 0) + .filter((integration) => !seen.has(integration.id) && supportsSettingsAuthentication(integration.methods)) .sort((a, b) => a.name.localeCompare(b.name)) .map((integration): ProviderEntry => ({ id: integration.id, @@ -84,11 +88,15 @@ export function ProviderSettings() { const providers = providersData?.providers - const { data: integrations, isLoading: integrationsLoading } = useProviderIntegrations() + const { + data: integrations, + isLoading: integrationsLoading, + isError: integrationsError, + } = useProviderIntegrations() const providerEntries = useMemo( - () => buildProviderEntries(providers, integrations), - [providers, integrations], + () => integrationsError ? [] : buildProviderEntries(providers, integrations), + [providers, integrations, integrationsError], ) const deleteCredentialMutation = useMutation({ @@ -189,6 +197,17 @@ export function ProviderSettings() { } }, []) + if (integrationsError) { + return ( +
+

Unable to load provider integrations.

+

+ Provider connection status and available authentication methods could not be verified. Reload the settings page to try again. +

+
+ ) + } + if (providersLoading || integrationsLoading) { return (
From d07c468a4ddb4c156a2e9d7938d2254eed0d440f Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:55:07 -0500 Subject: [PATCH 03/19] test(frontend): cover provider integration failures --- .../settings/ProviderSettings.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/components/settings/ProviderSettings.test.tsx b/frontend/src/components/settings/ProviderSettings.test.tsx index 10dae4848..4bfe82980 100644 --- a/frontend/src/components/settings/ProviderSettings.test.tsx +++ b/frontend/src/components/settings/ProviderSettings.test.tsx @@ -331,4 +331,32 @@ describe('ProviderSettings', () => { await screen.findByText('GitHub Copilot') expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument() }) + + it('does not list integration-only entries without OAuth or API-key methods', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('command-only', 'Command Only', [ + { type: 'command', label: 'Command' } as IntegrationMethod, + ]), + integrationFixture('env-only', 'Environment Only', [ + { type: 'env', label: 'Environment' } as IntegrationMethod, + ]), + ]) + renderSettings() + + await screen.findByText('No OAuth-capable providers available.') + + expect(screen.queryByText('Command Only')).not.toBeInTheDocument() + expect(screen.queryByText('Environment Only')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Available Providers/ })).toHaveTextContent('0') + }) + + it('shows an integration-list error instead of guessing connection status', async () => { + vi.mocked(oauthApi.getIntegrations).mockRejectedValue(new Error('Server unavailable')) + renderSettings() + + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to load provider integrations.') + expect(screen.queryByText('Anthropic')).not.toBeInTheDocument() + expect(screen.queryByText('No OAuth-capable providers available.')).not.toBeInTheDocument() + }) }) From 3b91c2f1a24ce6fe6006339a2917f00d74d39187 Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:04 -0500 Subject: [PATCH 04/19] fix(frontend): preserve provider integration IDs --- frontend/src/api/providers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index e1906dfdc..6ab812c30 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -32,6 +32,7 @@ export interface Model { export interface Provider { id: string; + integrationID?: string; name: string; models: Model[]; } @@ -83,6 +84,7 @@ export async function getProviders(directory?: string, serverId?: string): Promi return { id: provider.id, + integrationID: provider.integrationID, name: provider.name, models, }; From f1868ad3e3ccfda0d2430831a0c3e0dcf7a0350d Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:06 -0500 Subject: [PATCH 05/19] fix(frontend): match settings by integration ID --- frontend/src/components/settings/ProviderSettings.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index 30dc56650..222e002ce 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -45,10 +45,11 @@ function buildProviderEntries( const entries: ProviderEntry[] = [] for (const provider of providers ?? []) { - const integration = integrationsByID.get(provider.id) - seen.add(provider.id) + const integrationID = provider.integrationID ?? provider.id + const integration = integrationsByID.get(integrationID) + seen.add(integrationID) entries.push({ - id: provider.id, + id: integrationID, name: integration?.name || provider.name || provider.id, models: provider.models, methods: integration?.methods ?? [], From ed7ec938f1808ea61dd6148795ad3ea977e753bf Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:09 -0500 Subject: [PATCH 06/19] test(frontend): cover differing provider integration IDs --- .../settings/ProviderSettings.test.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/settings/ProviderSettings.test.tsx b/frontend/src/components/settings/ProviderSettings.test.tsx index 4bfe82980..09e14bf08 100644 --- a/frontend/src/components/settings/ProviderSettings.test.tsx +++ b/frontend/src/components/settings/ProviderSettings.test.tsx @@ -85,9 +85,10 @@ vi.mock('@/components/ui/delete-dialog', () => ({ }) => (open ? : null), })) -function providerFixture(id: string, name: string, modelCount: number): Provider { +function providerFixture(id: string, name: string, modelCount: number, integrationID?: string): Provider { return { id, + integrationID, name, models: Array.from({ length: modelCount }, (_, index) => ({ id: `model-${index}`, @@ -321,6 +322,26 @@ describe('ProviderSettings', () => { expect(screen.getByTestId('api-key-dialog')).toHaveTextContent('OpenRouter') }) + it('uses integration IDs for provider entries when catalog IDs differ', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [providerFixture('catalog-anthropic', 'Anthropic Catalog', 2, 'anthropic')], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('anthropic', 'Anthropic Integration', [oauthMethod], true), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('Anthropic Integration') + expect(screen.getByText('2 models')).toBeInTheDocument() + expect(screen.getByText('Connected')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Reconnect' })) + expect(screen.getByTestId('oauth-authorize-dialog')).toHaveTextContent('anthropic') + expect(screen.queryByText('catalog-anthropic')).not.toBeInTheDocument() + }) + it('lists OAuth integrations that are not in the active catalog', async () => { vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ From e4435738fb5db9720a216513c2141726ca62ce54 Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:09:21 -0500 Subject: [PATCH 07/19] fix settings grouping and mixed-method API keys --- .../components/settings/ProviderSettings.tsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index 222e002ce..965fdd070 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -22,12 +22,19 @@ interface ProviderEntry { models: Provider['models'] methods: IntegrationMethod[] connected: boolean + keyConnected: boolean } function hasCredentialConnection(integration: IntegrationInfo): boolean { return integration.connections.some((connection) => connection.type === 'credential') } +function hasKeyConnection(integration: IntegrationInfo): boolean { + return integration.connections.some( + (connection) => connection.type === 'credential' && connection.method === 'key', + ) +} + function supportsOAuthMethods(methods: IntegrationMethod[]): boolean { return methods.some((method) => method.type === 'oauth') } @@ -42,19 +49,30 @@ function buildProviderEntries( ): ProviderEntry[] { const integrationsByID = new Map((integrations ?? []).map((integration) => [integration.id, integration])) const seen = new Set() + const entriesByID = new Map() const entries: ProviderEntry[] = [] for (const provider of providers ?? []) { const integrationID = provider.integrationID ?? provider.id const integration = integrationsByID.get(integrationID) seen.add(integrationID) - entries.push({ + + const existingEntry = entriesByID.get(integrationID) + if (existingEntry) { + existingEntry.models.push(...provider.models) + continue + } + + const entry: ProviderEntry = { id: integrationID, name: integration?.name || provider.name || provider.id, - models: provider.models, + models: [...provider.models], methods: integration?.methods ?? [], connected: integration ? hasCredentialConnection(integration) : false, - }) + keyConnected: integration ? hasKeyConnection(integration) : false, + } + entriesByID.set(integrationID, entry) + entries.push(entry) } const integrationOnly = (integrations ?? []) @@ -66,6 +84,7 @@ function buildProviderEntries( models: [], methods: integration.methods, connected: hasCredentialConnection(integration), + keyConnected: hasKeyConnection(integration), })) return [...entries, ...integrationOnly] @@ -239,7 +258,8 @@ export function ProviderSettings() { ) : (
{oauthProviders.map((provider) => { - const hasKey = provider.connected + const isConnected = provider.connected + const keyConnected = provider.keyConnected const modelCount = provider.models.length return ( @@ -255,7 +275,7 @@ export function ProviderSettings() { )}
- {hasKey ? ( + {isConnected ? ( Connected @@ -268,16 +288,27 @@ export function ProviderSettings() { )} - {hasKey && ( + {provider.methods.some((method) => method.type === 'key') && ( + + )} + {isConnected && ( )} {isConnected && ( From 8156c76d9631b3fbd6d59e57cc210b9a7f4e4b28 Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:33:29 -0500 Subject: [PATCH 10/19] test API key action label --- frontend/src/components/settings/ProviderSettings.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/settings/ProviderSettings.test.tsx b/frontend/src/components/settings/ProviderSettings.test.tsx index daa1abffc..d91b02ad5 100644 --- a/frontend/src/components/settings/ProviderSettings.test.tsx +++ b/frontend/src/components/settings/ProviderSettings.test.tsx @@ -394,7 +394,7 @@ describe('ProviderSettings', () => { expect(screen.getByTestId('api-key-dialog')).toHaveAttribute('data-mode', 'add') }) - it('uses key-specific connection state for mixed-method integration actions', async () => { + it('labels an existing API key action as Add API key without changing its handling', async () => { vi.mocked(getProviders).mockResolvedValue({ providers: [providerFixture('anthropic', 'Anthropic', 2)], models: [], @@ -408,7 +408,10 @@ describe('ProviderSettings', () => { await screen.findByText('Anthropic') const row = screen.getByText('Anthropic').closest('.py-3') as HTMLElement - await user.click(within(row).getByRole('button', { name: 'Edit API key for Anthropic' })) + const addKeyButton = within(row).getByRole('button', { name: 'Add API key for Anthropic' }) + expect(addKeyButton).toHaveTextContent('Add API key') + + await user.click(addKeyButton) expect(screen.getByTestId('api-key-dialog')).toHaveAttribute('data-mode', 'edit') }) From 742a5076febab4018a1d806e42794b67466fd09d Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:47:30 -0500 Subject: [PATCH 11/19] filter API key providers by key method --- frontend/src/components/settings/ProviderSettings.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index 9f269dc55..3210349c2 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -166,7 +166,11 @@ export function ProviderSettings() { }, [providerEntries]) const apiKeyProviders = useMemo(() => { - const nonOAuthProviders = providerEntries.filter((provider) => !supportsOAuthMethods(provider.methods)) + const nonOAuthProviders = providerEntries.filter( + (provider) => + !supportsOAuthMethods(provider.methods) && + provider.methods.some((method) => method.type === 'key'), + ) return { connected: nonOAuthProviders.filter((provider) => provider.connected), available: nonOAuthProviders.filter((provider) => !provider.connected), From a6a8121390ee62f1b370040e6e4180f5a6609f27 Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:47:43 -0500 Subject: [PATCH 12/19] test filtering non-key integrations from API key list --- .../settings/ProviderSettings.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/frontend/src/components/settings/ProviderSettings.test.tsx b/frontend/src/components/settings/ProviderSettings.test.tsx index d91b02ad5..7281180d0 100644 --- a/frontend/src/components/settings/ProviderSettings.test.tsx +++ b/frontend/src/components/settings/ProviderSettings.test.tsx @@ -441,6 +441,27 @@ describe('ProviderSettings', () => { expect(screen.getByRole('button', { name: /Available Providers/ })).toHaveTextContent('0') }) + it('does not offer API-key actions for catalog providers without a key method', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [ + providerFixture('command-only', 'Command Only', 1), + providerFixture('env-only', 'Environment Only', 1), + providerFixture('empty-methods', 'No Auth Methods', 1), + ], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('command-only', 'Command Only', [commandMethod]), + integrationFixture('env-only', 'Environment Only', [envMethod]), + integrationFixture('empty-methods', 'No Auth Methods', []), + ]) + renderSettings() + + expect(await screen.findByRole('button', { name: /Available Providers/ })).toHaveTextContent('0') + expect(screen.getByText('No available providers.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add Key' })).not.toBeInTheDocument() + }) + it('shows an integration-list error instead of guessing connection status', async () => { vi.mocked(oauthApi.getIntegrations).mockRejectedValue(new Error('Server unavailable')) renderSettings() From 02f1bc2a3df19061bc8210a4b170f64a1e065dca Mon Sep 17 00:00:00 2001 From: Wesley Tanner <137104411+fireball71@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:07:08 -0500 Subject: [PATCH 13/19] keep connected providers visible without key methods --- .../components/settings/ProviderSettings.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index 3210349c2..d7fc3186f 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -169,7 +169,7 @@ export function ProviderSettings() { const nonOAuthProviders = providerEntries.filter( (provider) => !supportsOAuthMethods(provider.methods) && - provider.methods.some((method) => method.type === 'key'), + (provider.connected || provider.methods.some((method) => method.type === 'key')), ) return { connected: nonOAuthProviders.filter((provider) => provider.connected), @@ -405,15 +405,17 @@ export function ProviderSettings() { Connected - + {provider.methods.some((method) => method.type === 'key') && ( + + )}