Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
024b3d9
fix(frontend): list provider integrations in settings
fireball71 Oct 29, 2026
ee1a39b
fix(frontend): handle provider integration errors
fireball71 Oct 29, 2026
d07c468
test(frontend): cover provider integration failures
fireball71 Oct 29, 2026
3b91c2f
fix(frontend): preserve provider integration IDs
fireball71 Oct 29, 2026
f1868ad
fix(frontend): match settings by integration ID
fireball71 Oct 29, 2026
ed7ec93
test(frontend): cover differing provider integration IDs
fireball71 Oct 29, 2026
e443573
fix settings grouping and mixed-method API keys
fireball71 Oct 29, 2026
dc1c40d
test provider grouping and mixed auth methods
fireball71 Oct 29, 2026
e983e85
fix API key action label
fireball71 Oct 29, 2026
8156c76
test API key action label
fireball71 Oct 29, 2026
742a507
filter API key providers by key method
fireball71 Oct 29, 2026
a6a8121
test filtering non-key integrations from API key list
fireball71 Oct 29, 2026
02f1bc2
keep connected providers visible without key methods
fireball71 Oct 29, 2026
8cc524b
test visibility of connected non-key providers
fireball71 Oct 29, 2026
94a3519
refresh provider caches on config and stream refreshes
fireball71 Oct 29, 2026
a049f84
test provider cache refresh event paths
fireball71 Oct 29, 2026
08d336c
show connected integration-only providers
fireball71 Oct 29, 2026
74de8ac
test connected integration-only provider visibility
fireball71 Oct 29, 2026
cb7d073
Merge branch 'main' into Fix-0-Providers
fireball71 Oct 30, 2026
ca26875
fix(frontend): invalidate provider caches on remote resync
fireball71 Oct 30, 2026
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
35 changes: 35 additions & 0 deletions backend/src/routes/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions backend/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/api/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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"

export type {
FormAnswer,
FormField,
FormValue,
IntegrationInfo,
IntegrationKeyMethod,
IntegrationMethod,
IntegrationOAuthMethod,
Expand Down Expand Up @@ -45,4 +46,11 @@ export const oauthApi = {
)
return providers
},

getIntegrations: async (): Promise<IntegrationInfo[]> => {
const { integrations } = await fetchWrapper<{ integrations: IntegrationInfo[] }>(
`${API_BASE_URL}/api/oauth/integrations`
)
return integrations
},
}
2 changes: 2 additions & 0 deletions frontend/src/api/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Model {

export interface Provider {
id: string;
integrationID?: string;
name: string;
models: Model[];
}
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function getProviders(directory?: string, serverId?: string): Promi

return {
id: provider.id,
integrationID: provider.integrationID,
name: provider.name,
models,
};
Expand Down
235 changes: 227 additions & 8 deletions frontend/src/components/settings/ProviderSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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(),
},
Expand Down Expand Up @@ -84,9 +85,10 @@ vi.mock('@/components/ui/delete-dialog', () => ({
}) => (open ? <button onClick={onConfirm}>Confirm delete</button> : 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}`,
Expand All @@ -102,6 +104,33 @@ 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' }
const commandMethod: IntegrationMethod = {
id: 'command',
type: 'command',
label: 'Command',
command: ['example-command'],
}
const envMethod: IntegrationMethod = { type: 'env', names: ['ENV_ONLY_API_KEY'] }

function integrationFixture(
id: string,
name: string,
methods: IntegrationMethod[],
connected = false,
connectionMethod: 'key' | 'oauth' = 'key',
): IntegrationInfo {
return {
id,
name,
methods,
connections: connected
? [{ type: 'credential', id: `cred_${id}`, label: name, method: connectionMethod }]
: [],
}
}

function mockProviderData() {
vi.mocked(getProviders).mockResolvedValue({
providers: [
Expand All @@ -112,11 +141,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() {
Expand Down Expand Up @@ -270,11 +300,200 @@ 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('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('groups catalog providers that share an integration and combines their models', async () => {
vi.mocked(getProviders).mockResolvedValue({
providers: [
providerFixture('catalog-one', 'Catalog One', 2, 'shared-integration'),
providerFixture('catalog-two', 'Catalog Two', 3, 'shared-integration'),
],
models: [],
})
vi.mocked(oauthApi.getIntegrations).mockResolvedValue([
integrationFixture('shared-integration', 'Shared Integration', [oauthMethod]),
])
const user = userEvent.setup()
renderSettings()

await screen.findByText('Shared Integration')

expect(screen.getAllByText('Shared Integration')).toHaveLength(1)
expect(screen.getByText('5 models')).toBeInTheDocument()

await user.click(screen.getByRole('button', { name: 'Connect' }))
expect(screen.getByTestId('oauth-authorize-dialog')).toHaveTextContent('shared-integration')
})

it('offers an API-key action for mixed-method integrations even when connected with OAuth', async () => {
vi.mocked(getProviders).mockResolvedValue({
providers: [providerFixture('anthropic', 'Anthropic', 2)],
models: [],
})
vi.mocked(oauthApi.getIntegrations).mockResolvedValue([
integrationFixture('anthropic', 'Anthropic', [oauthMethod, keyMethod], true, 'oauth'),
])
const user = userEvent.setup()
renderSettings()

await screen.findByText('Anthropic')
const row = screen.getByText('Anthropic').closest('.py-3') as HTMLElement

expect(within(row).getByRole('button', { name: 'Reconnect' })).toBeInTheDocument()
expect(within(row).getByRole('button', { name: 'Add API key for Anthropic' })).toBeInTheDocument()

await user.click(within(row).getByRole('button', { name: 'Add API key for Anthropic' }))
expect(screen.getByTestId('api-key-dialog')).toHaveAttribute('data-mode', 'add')
})

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: [],
})
vi.mocked(oauthApi.getIntegrations).mockResolvedValue([
integrationFixture('anthropic', 'Anthropic', [oauthMethod, keyMethod], true, 'key'),
])
const user = userEvent.setup()
renderSettings()

await screen.findByText('Anthropic')
const row = screen.getByText('Anthropic').closest('.py-3') as HTMLElement

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')
})

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()
})

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', [commandMethod]),
integrationFixture('env-only', 'Environment Only', [envMethod]),
])
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('includes connected integration-only providers without key or OAuth methods', async () => {
vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] })
vi.mocked(oauthApi.getIntegrations).mockResolvedValue([
integrationFixture('connected-command', 'Connected Command', [commandMethod], true),
])
const user = userEvent.setup()
renderSettings()

await screen.findByText('No OAuth-capable providers available.')
expect(screen.getByRole('button', { name: /Connected/ })).toHaveTextContent('1')

await user.click(screen.getByRole('button', { name: /Connected/ }))
const connectedCard = screen.getByText('Connected Command').closest('.bg-card') as HTMLElement
expect(connectedCard).toBeInTheDocument()
expect(within(connectedCard).getByRole('button', { name: 'Remove credentials for Connected Command' })).toBeInTheDocument()
expect(within(connectedCard).queryByRole('button', { name: 'Edit API key for Connected Command' })).not.toBeInTheDocument()
})

it('keeps connected non-key providers visible without exposing API-key edit actions', async () => {
vi.mocked(getProviders).mockResolvedValue({
providers: [
providerFixture('command-only', 'Command Only', 1),
providerFixture('command-connected', 'Connected 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('command-connected', 'Connected Command Only', [commandMethod], true),
integrationFixture('env-only', 'Environment Only', [envMethod]),
integrationFixture('empty-methods', 'No Auth Methods', []),
])
const user = userEvent.setup()
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()

await user.click(screen.getByRole('button', { name: /Connected/ }))
const connectedCard = screen.getByText('Connected Command Only').closest('.bg-card') as HTMLElement
expect(within(connectedCard).getByRole('button', { name: 'Remove credentials for Connected Command Only' })).toBeInTheDocument()
expect(within(connectedCard).queryByRole('button', { name: 'Edit API key for Connected Command Only' })).not.toBeInTheDocument()
})

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()
})
})
Loading