From 82bb814af78f97bf4d4ce56f1692cd015bd2730a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 14:55:51 +0000 Subject: [PATCH 1/9] fix(providers): refresh credentials after auth save Stop treating Provider.env length as credentials, refetch provider sources after OAuth/API key writes, and respect an explicit auth-panel Hide so OAuth-only providers do not stick on a stale "Credentials missing" / empty-models state after a successful login. Co-authored-by: Serhii Dziupin --- .../sections/providers/ProvidersPage.test.ts | 108 +++++++++++- .../sections/providers/ProvidersPage.tsx | 159 +++++++++++++++--- .../sections/providers/providerAuth.ts | 30 ++++ 3 files changed, 269 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index 05050304..4445ca78 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -4,8 +4,11 @@ import { getOAuthAuthMethods, normalizeAuthType, parseAuthPayload, - requiresOpenCodeRestartAfterOAuth, +requiresOpenCodeRestartAfterOAuth, + providerHasCredentials, + shouldAutoOpenAuthPanel, shouldShowApiKeyAuth, + shouldShowModelsSection, } from './providerAuth'; describe('ProvidersPage available provider loading', () => { @@ -72,3 +75,106 @@ describe('provider auth method helpers', () => { expect(requiresOpenCodeRestartAfterOAuth('github-copilot')).toBe(true); }); }); + +describe('provider credential state helpers', () => { + test('providerHasCredentials ignores declared env names and requires key or auth source', () => { + // Built-in catalog entry with env var names but no actual credential. + expect(providerHasCredentials({ key: undefined, authSourceExists: false })).toBe(false); + expect(providerHasCredentials({ key: '', authSourceExists: false })).toBe(false); + expect(providerHasCredentials({ key: ' ', authSourceExists: false })).toBe(false); + + // OpenCode reports an active credential via provider.key. + expect(providerHasCredentials({ key: 'sk-...', authSourceExists: false })).toBe(true); + // Auth.json provenance alone is enough while sources are authoritative. + expect(providerHasCredentials({ key: undefined, authSourceExists: true })).toBe(true); + }); + + test('env-less OAuth-only provider without credentials opens panel and hides models', () => { + const hasCredentials = providerHasCredentials({ + key: undefined, + authSourceExists: false, + }); + expect(hasCredentials).toBe(false); + expect(shouldAutoOpenAuthPanel({ + sourcesLoaded: true, + hasCredentials, + userDismissed: false, + })).toBe(true); + expect(shouldShowModelsSection({ + modelCount: 1, + sourcesLoaded: true, + hasCredentials, + })).toBe(false); + }); + + test('provider with stored auth or key shows Connected and models', () => { + const fromKey = providerHasCredentials({ key: 'sk-live', authSourceExists: false }); + const fromAuth = providerHasCredentials({ key: undefined, authSourceExists: true }); + expect(fromKey).toBe(true); + expect(fromAuth).toBe(true); + expect(shouldAutoOpenAuthPanel({ + sourcesLoaded: true, + hasCredentials: fromKey, + userDismissed: false, + })).toBe(false); + expect(shouldShowModelsSection({ + modelCount: 3, + sourcesLoaded: true, + hasCredentials: fromAuth, + })).toBe(true); + }); + + test('auth save followed by providers refresh recognizes credentials without stale missing state', () => { + // Pre-save: sources say no auth, provider has no key yet. + const before = providerHasCredentials({ + key: undefined, + authSourceExists: false, + }); + expect(before).toBe(false); + expect(shouldShowModelsSection({ + modelCount: 2, + sourcesLoaded: true, + hasCredentials: before, + })).toBe(false); + + // After reloadOpenCodeConfiguration, providers array gets a key even if the + // sources snapshot has not been refetched yet. + const afterProvidersRefresh = providerHasCredentials({ + key: 'oauth-token-present', + authSourceExists: false, + }); + expect(afterProvidersRefresh).toBe(true); + expect(shouldAutoOpenAuthPanel({ + sourcesLoaded: true, + hasCredentials: afterProvidersRefresh, + userDismissed: false, + })).toBe(false); + expect(shouldShowModelsSection({ + modelCount: 2, + sourcesLoaded: true, + hasCredentials: afterProvidersRefresh, + })).toBe(true); + + // After sources refetch completes, auth.exists also becomes true. + expect(providerHasCredentials({ + key: 'oauth-token-present', + authSourceExists: true, + })).toBe(true); + }); + + test('explicit hide keeps the auth panel closed while credentials are still missing', () => { + expect(shouldAutoOpenAuthPanel({ + sourcesLoaded: true, + hasCredentials: false, + userDismissed: true, + })).toBe(false); + }); + + test('models stay visible while sources are still loading', () => { + expect(shouldShowModelsSection({ + modelCount: 4, + sourcesLoaded: false, + hasCredentials: false, + })).toBe(true); + }); +}); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 9ca24ca5..3a4935dd 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -29,8 +29,11 @@ import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAv import { getOAuthAuthMethods, parseAuthPayload, - requiresOpenCodeRestartAfterOAuth, +requiresOpenCodeRestartAfterOAuth, + providerHasCredentials, + shouldAutoOpenAuthPanel, shouldShowApiKeyAuth, + shouldShowModelsSection, type AuthMethod, type OAuthAuthMethodEntry, } from './providerAuth'; @@ -170,7 +173,11 @@ export const ProvidersPage: React.FC = () => { const [providerSearchQuery, setProviderSearchQuery] = React.useState(''); const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false); const [providerSources, setProviderSources] = React.useState>({}); + // Bumped after auth writes so the source snapshot is refetched even when the + // selected provider id is unchanged (OAuth/API key success path). + const [providerSourcesRevision, setProviderSourcesRevision] = React.useState(0); const [showAuthPanel, setShowAuthPanel] = React.useState(false); + const [authPanelDismissedForId, setAuthPanelDismissedForId] = React.useState(null); const [editingCustomProviderId, setEditingCustomProviderId] = React.useState(null); const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState(null); const [editingCustomScope, setEditingCustomScope] = React.useState(null); @@ -298,6 +305,7 @@ export const ProvidersPage: React.FC = () => { React.useEffect(() => { if (selectedProviderId === ADD_PROVIDER_ID) { setShowAuthPanel(true); + setAuthPanelDismissedForId(null); setEditingCustomProviderId(null); setEditingCustomFormInitial(null); setEditingCustomScope(null); @@ -306,6 +314,7 @@ export const ProvidersPage: React.FC = () => { } setShowAuthPanel(false); + setAuthPanelDismissedForId(null); if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) { setEditingCustomProviderId(null); setEditingCustomFormInitial(null); @@ -315,7 +324,7 @@ export const ProvidersPage: React.FC = () => { }, [selectedProviderId, editingCustomProviderId]); // Unauthenticated providers (OAuth-only plugins before login) should open the - // auth panel instead of a false "Connected" summary. + // auth panel instead of a false "Connected" summary. Respect an explicit Hide. React.useEffect(() => { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { return; @@ -325,15 +334,20 @@ export const ProvidersPage: React.FC = () => { return; } const provider = providers.find((entry) => entry.id === selectedProviderId); - const envEntries = Array.isArray(provider?.env) - ? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - : []; - const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0; - const isCustomProvider = Boolean(provider && isConfigDefinedCustomProvider(provider, sources)); - if (requiresProviderAuth(true, hasCreds, isCustomProvider)) { + const hasCreds = providerHasCredentials({ + key: provider?.key, + authSourceExists: sources.auth.exists, + }); + if ( + shouldAutoOpenAuthPanel({ + sourcesLoaded: true, + hasCredentials: hasCreds, + userDismissed: authPanelDismissedForId === selectedProviderId, + }) + ) { setShowAuthPanel(true); } - }, [selectedProviderId, providerSources, providers]); + }, [selectedProviderId, providerSources, providers, authPanelDismissedForId]); React.useEffect(() => { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { @@ -376,7 +390,33 @@ export const ProvidersPage: React.FC = () => { return () => { cancelled = true; }; - }, [selectedProviderId, settingsDirectory, t]); + }, [selectedProviderId, providerSourcesRevision, t]); + + const refreshProviderSources = React.useCallback(() => { + setProviderSourcesRevision((revision) => revision + 1); + }, []); + + const markAuthWriteSucceeded = React.useCallback((providerId: string) => { + // Optimistically mark auth present so a providers refresh that has not yet + // stamped provider.key cannot reopen the panel / hide models with a stale + // "Credentials missing" summary before the source refetch lands. + setProviderSources((prev) => { + const existing = prev[providerId]; + return { + ...prev, + [providerId]: { + auth: { exists: true, path: existing?.auth.path ?? null }, + user: existing?.user ?? { exists: false, path: null }, + project: existing?.project ?? { exists: false, path: null }, + ...(existing?.custom ? { custom: existing.custom } : {}), + }, + }; + }); + setAuthPanelDismissedForId(null); + setShowAuthPanel(false); + setSelectedProvider(providerId); + refreshProviderSources(); + }, [refreshProviderSources, setSelectedProvider]); const selectedProvider = providers.find((provider) => provider.id === selectedProviderId); const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined; @@ -402,8 +442,8 @@ export const ProvidersPage: React.FC = () => { toast.success(t('settings.providers.page.toast.apiKeySaved')); setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); - recordDeferredOpenCodeRestart('providers', { id: providerId }); - setSelectedProvider(providerId); + await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + markAuthWriteSucceeded(providerId); } catch (error) { console.error('Failed to save API key:', error); toast.error(t('settings.providers.page.toast.apiKeySaveFailed')); @@ -460,8 +500,8 @@ export const ProvidersPage: React.FC = () => { setEditingCustomScope(null); setCustomAuthFailureHint(null); setLastCustomPersistId(null); - noteDeferredRestartFromPayload(payload, 'providers', { id: plan.providerID }); - setSelectedProvider(plan.providerID); + await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' }); + markAuthWriteSucceeded(plan.providerID); } catch (error) { console.error('Failed to save custom provider:', error); toast.error( @@ -482,7 +522,62 @@ export const ProvidersPage: React.FC = () => { if (requiresOpenCodeRestartAfterOAuth(providerId)) { recordDeferredOpenCodeRestart('providers', { id: providerId }); } - setSelectedProvider(providerId); + }; + + const handleOAuthComplete = async (providerId: string, methodIndex: number) => { + const codeKey = `${providerId}:${methodIndex}`; + const code = oauthCodes[codeKey]?.trim(); + + const busyKey = `oauth-complete:${providerId}:${methodIndex}`; + setAuthBusyKey(busyKey); + + try { + const requestBody: { method: number; code?: string } = { method: methodIndex }; + if (code) { + requestBody.code = code; + } + + const result = await opencodeClient.getSdkClient().provider.oauth.callback({ + providerID: providerId, + method: requestBody.method, + code: requestBody.code, + }); + if (result.error) { + throw new Error(t('settings.providers.page.toast.oauthCompleteFailed')); + } + + toast.success(t('settings.providers.page.toast.oauthCompleted')); + setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); + setPendingOAuth(null); + await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + markAuthWriteSucceeded(providerId); + } catch (error) { + console.error('Failed to complete OAuth flow:', error); + toast.error(t('settings.providers.page.toast.oauthCompleteFailed')); + } finally { + setAuthBusyKey(null); + } + }; + + const handleCopyOAuthLink = async (url: string) => { + const result = await copyTextToClipboard(url); + if (result.ok) { + toast.success(t('settings.providers.page.toast.oauthLinkCopied')); + return; + } + console.error('Failed to copy OAuth link:', result.error); + toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed')); + }; + + const handleCopyOAuthCode = async (code: string) => { + const result = await copyTextToClipboard(code); + if (result.ok) { + toast.success(t('settings.providers.page.toast.deviceCodeCopied')); + return; + } + console.error('Failed to copy device code:', result.error); + toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed')); +>>>>>>> e7ca14b2b (fix(providers): refresh credentials after auth save) }; const handleDisconnectProvider = async (providerId: string) => { @@ -506,7 +601,9 @@ export const ProvidersPage: React.FC = () => { toast.success(t('settings.providers.page.toast.providerDisconnected')); // Only accumulate when the server actually deferred a restart (e.g. auth removed). // removed:false payloads must not create a phantom pending Apply & Restart. - noteDeferredRestartFromPayload(payload, 'providers', { id: providerId }); + await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + setAuthPanelDismissedForId(null); + refreshProviderSources(); } catch (error) { console.error('Failed to disconnect provider:', error); toast.error(t('settings.providers.page.toast.providerDisconnectFailed')); @@ -771,18 +868,16 @@ export const ProvidersPage: React.FC = () => { const sourcesLoaded = Boolean(selectedSources); const isEditableCustomProvider = sourcesLoaded && isConfigDefinedCustomProvider(selectedProvider, selectedSources); - const providerEnv = Array.isArray(selectedProvider.env) - ? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - : []; - const hasStoredAuth = Boolean(selectedSources?.auth.exists); - const hasEnvCredentials = providerEnv.length > 0; - const hasCredentials = hasStoredAuth || hasEnvCredentials; - const authStatusIncomplete = requiresProviderAuth( + const hasCredentials = providerHasCredentials({ + key: selectedProvider.key, + authSourceExists: selectedSources?.auth.exists, + }); + const authStatusIncomplete = sourcesLoaded && !hasCredentials; + const showModelsSection = shouldShowModelsSection({ + modelCount: providerModels.length, sourcesLoaded, hasCredentials, - isEditableCustomProvider, - ); - const showModelsSection = providerModels.length > 0 && !authStatusIncomplete; + }); const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0 ? t('settings.providers.page.auth.useReconnectHint') : t('settings.providers.page.auth.incompleteHint'); @@ -852,7 +947,17 @@ export const ProvidersPage: React.FC = () => { variant="outline" size="xs" className="!font-normal" - onClick={() => setShowAuthPanel((prev) => !prev)} + onClick={() => { + setShowAuthPanel((prev) => { + const next = !prev; + if (!next) { + setAuthPanelDismissedForId(selectedProvider.id); + } else { + setAuthPanelDismissedForId(null); + } + return next; + }); + }} > {showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')} diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index ed6561b6..82a77825 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -60,3 +60,33 @@ export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry export const requiresOpenCodeRestartAfterOAuth = (providerId: string): boolean => providerId !== 'claude-code'; + +export interface ProviderCredentialInput { + /** Present when OpenCode reports an active credential (api/env/oauth). */ + key?: string | null; + /** OpenChamber auth.json provenance for this provider. */ + authSourceExists?: boolean | null; +} + +/** + * Prefer authoritative credential signals. Do not treat Provider.env length as + * proof of credentials — that array is declared env var *names*, not values. + */ +export const providerHasCredentials = (input: ProviderCredentialInput): boolean => { + if (typeof input.key === 'string' && input.key.trim().length > 0) { + return true; + } + return input.authSourceExists === true; +}; + +export const shouldShowModelsSection = (input: { + modelCount: number; + sourcesLoaded: boolean; + hasCredentials: boolean; +}): boolean => input.modelCount > 0 && (!input.sourcesLoaded || input.hasCredentials); + +export const shouldAutoOpenAuthPanel = (input: { + sourcesLoaded: boolean; + hasCredentials: boolean; + userDismissed: boolean; +}): boolean => input.sourcesLoaded && !input.hasCredentials && !input.userDismissed; From 08883ff98f9cc04d6248e6b5f9b8f00f7b111137 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 20 Aug 2026 18:20:53 +0200 Subject: [PATCH 2/9] fix(providers): import helpers, declare oauth state, drop conflict marker --- .../ui/src/components/sections/providers/ProvidersPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 3a4935dd..44f228ae 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -19,8 +19,10 @@ import { import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; +import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { cn } from '@/lib/utils'; +import { copyTextToClipboard } from '@/lib/clipboard'; import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -164,6 +166,8 @@ export const ProvidersPage: React.FC = () => { const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState>({}); const [authLoading, setAuthLoading] = React.useState(false); const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); + const [oauthCodes, setOauthCodes] = React.useState>({}); + const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null); const [authBusyKey, setAuthBusyKey] = React.useState(null); const [modelQuery, setModelQuery] = React.useState(''); const [availableProviders, setAvailableProviders] = React.useState([]); @@ -577,7 +581,6 @@ export const ProvidersPage: React.FC = () => { } console.error('Failed to copy device code:', result.error); toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed')); ->>>>>>> e7ca14b2b (fix(providers): refresh credentials after auth save) }; const handleDisconnectProvider = async (providerId: string) => { From 06f87c17b0e41e8edda141e625266a4fb1bfd8e8 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 20 Aug 2026 18:49:38 +0200 Subject: [PATCH 3/9] fix(providers): drop dead handlers from rebased PR --- .../sections/providers/ProvidersPage.tsx | 60 +------------------ 1 file changed, 2 insertions(+), 58 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 44f228ae..e0336df3 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -20,14 +20,14 @@ import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; -import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; +import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { opencodeClient } from '@/lib/opencode/client'; -import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability'; +import { shouldLoadAvailableProviders } from './providerAvailability'; import { getOAuthAuthMethods, parseAuthPayload, @@ -167,7 +167,6 @@ export const ProvidersPage: React.FC = () => { const [authLoading, setAuthLoading] = React.useState(false); const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); const [oauthCodes, setOauthCodes] = React.useState>({}); - const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null); const [authBusyKey, setAuthBusyKey] = React.useState(null); const [modelQuery, setModelQuery] = React.useState(''); const [availableProviders, setAvailableProviders] = React.useState([]); @@ -528,61 +527,6 @@ export const ProvidersPage: React.FC = () => { } }; - const handleOAuthComplete = async (providerId: string, methodIndex: number) => { - const codeKey = `${providerId}:${methodIndex}`; - const code = oauthCodes[codeKey]?.trim(); - - const busyKey = `oauth-complete:${providerId}:${methodIndex}`; - setAuthBusyKey(busyKey); - - try { - const requestBody: { method: number; code?: string } = { method: methodIndex }; - if (code) { - requestBody.code = code; - } - - const result = await opencodeClient.getSdkClient().provider.oauth.callback({ - providerID: providerId, - method: requestBody.method, - code: requestBody.code, - }); - if (result.error) { - throw new Error(t('settings.providers.page.toast.oauthCompleteFailed')); - } - - toast.success(t('settings.providers.page.toast.oauthCompleted')); - setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); - setPendingOAuth(null); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); - markAuthWriteSucceeded(providerId); - } catch (error) { - console.error('Failed to complete OAuth flow:', error); - toast.error(t('settings.providers.page.toast.oauthCompleteFailed')); - } finally { - setAuthBusyKey(null); - } - }; - - const handleCopyOAuthLink = async (url: string) => { - const result = await copyTextToClipboard(url); - if (result.ok) { - toast.success(t('settings.providers.page.toast.oauthLinkCopied')); - return; - } - console.error('Failed to copy OAuth link:', result.error); - toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed')); - }; - - const handleCopyOAuthCode = async (code: string) => { - const result = await copyTextToClipboard(code); - if (result.ok) { - toast.success(t('settings.providers.page.toast.deviceCodeCopied')); - return; - } - console.error('Failed to copy device code:', result.error); - toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed')); - }; - const handleDisconnectProvider = async (providerId: string) => { const busyKey = `disconnect:${providerId}`; setAuthBusyKey(busyKey); From d0e594ab1e98dca8bb816a0c5904f5dbc0671e5b Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 28 Aug 2026 14:35:50 +0200 Subject: [PATCH 4/9] docs(changelog): [Unreleased] entry for providers credential refresh (#2884) --- CHANGELOG.md | 1 + .../sections/providers/ProvidersPage.tsx | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4cc429..9bc6d808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). - Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. +- Providers: after saving a provider's credentials the settings page now refreshes the credential state (source refetch + optimistic auth mark) instead of keeping the stale "Credentials missing" / empty-models summary until the next restart (thanks to @herjarsa). - Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. ## [1.21.0] - 2026-08-26 diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index e0336df3..bddc99eb 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -20,6 +20,7 @@ import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; +import type { ConfigChangeScope } from '@/lib/configSync'; import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; @@ -421,6 +422,32 @@ export const ProvidersPage: React.FC = () => { refreshProviderSources(); }, [refreshProviderSources, setSelectedProvider]); + // The mutation above already persisted to disk. If OpenCode is externally + // managed (e.g. the user is running a separate `opencode serve` they have to + // restart themselves), reloadOpenCodeConfiguration throws with + // `requiresManualRestart`. Surface the restart guidance instead of a + // misleading "mutation failed" toast and ensure the deferred-restart + // payload is recorded so the Settings page can show pending-restart + // guidance consistently across providers, API keys, custom providers, + // and disconnects. + const applyConfigReloadOrRecordDeferred = React.useCallback( + async (scope: ConfigChangeScope, idForDeferred?: string) => { + try { + await reloadOpenCodeConfiguration({ scopes: [scope], mode: 'active' }); + return 'reloaded'; + } catch (error) { + const requiresManual = (error as Error & { requiresManualRestart?: boolean })?.requiresManualRestart === true; + if (requiresManual) { + if (idForDeferred) { + recordDeferredOpenCodeRestart(scope, { id: idForDeferred }); + } + return 'manual-restart'; + } + throw error; + } + }, + [], + ); const selectedProvider = providers.find((provider) => provider.id === selectedProviderId); const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined; @@ -445,7 +472,11 @@ export const ProvidersPage: React.FC = () => { toast.success(t('settings.providers.page.toast.apiKeySaved')); setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + // Mutation succeeded: the auth key is on disk. The reload can fail with + // requiresManualRestart when OpenCode is externally managed; the helper + // records the deferred-restart payload instead of throwing a misleading + // "mutation failed" toast. + await applyConfigReloadOrRecordDeferred('providers', providerId); markAuthWriteSucceeded(providerId); } catch (error) { console.error('Failed to save API key:', error); From addcbfe4473b16e5d54b8a4ed31b489e0b96575a Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 28 Aug 2026 15:13:23 +0200 Subject: [PATCH 5/9] fix(providers): address bot blockers for stale Credentials missing state - shouldAutoOpenAuthPanel: re-introduce isEditableCustomProvider exemption (was dropped when requiresProviderAuth was replaced). Custom providers are editable directly in the form and must not be force-opened into the auth panel on a stale sources snapshot. - handleSaveCustomProvider + handleDisconnectProvider: route through applyConfigReloadOrRecordDeferred so an externally managed OpenCode that throws requiresManualRestart records deferred-restart guidance instead of toasting a misleading 'mutation failed' for a write that already persisted. - handleOAuthConnected: call markAuthWriteSucceeded so the page does not stick on a stale 'Credentials missing' summary while the providers refresh lands (OAuth previously only updated the deferred-restart payload). - Sources effect deps: add settingsDirectory back so a directory switch while the provider id is unchanged refetches the source snapshot (was swapped for providerSourcesRevision in the prior rebase). - Cleanup: drop dead oauthCodes state and copyTextToClipboard import; restore the requiresOpenCodeRestartAfterOAuth import alignment. 13/13 ProvidersPage.test.ts still green. --- .../sections/providers/ProvidersPage.tsx | 23 ++++++++++++++----- .../sections/providers/providerAuth.ts | 15 +++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index bddc99eb..e6f35a0c 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -23,7 +23,6 @@ import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import type { ConfigChangeScope } from '@/lib/configSync'; import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { cn } from '@/lib/utils'; -import { copyTextToClipboard } from '@/lib/clipboard'; import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -32,8 +31,8 @@ import { shouldLoadAvailableProviders } from './providerAvailability'; import { getOAuthAuthMethods, parseAuthPayload, -requiresOpenCodeRestartAfterOAuth, providerHasCredentials, + requiresOpenCodeRestartAfterOAuth, shouldAutoOpenAuthPanel, shouldShowApiKeyAuth, shouldShowModelsSection, @@ -167,7 +166,6 @@ export const ProvidersPage: React.FC = () => { const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState>({}); const [authLoading, setAuthLoading] = React.useState(false); const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); - const [oauthCodes, setOauthCodes] = React.useState>({}); const [authBusyKey, setAuthBusyKey] = React.useState(null); const [modelQuery, setModelQuery] = React.useState(''); const [availableProviders, setAvailableProviders] = React.useState([]); @@ -342,11 +340,15 @@ export const ProvidersPage: React.FC = () => { key: provider?.key, authSourceExists: sources.auth.exists, }); + const isEditableCustomProvider = Boolean( + provider && isConfigDefinedCustomProvider(provider, sources) + ); if ( shouldAutoOpenAuthPanel({ sourcesLoaded: true, hasCredentials: hasCreds, userDismissed: authPanelDismissedForId === selectedProviderId, + isEditableCustomProvider, }) ) { setShowAuthPanel(true); @@ -394,7 +396,7 @@ export const ProvidersPage: React.FC = () => { return () => { cancelled = true; }; - }, [selectedProviderId, providerSourcesRevision, t]); + }, [selectedProviderId, providerSourcesRevision, settingsDirectory, t]); const refreshProviderSources = React.useCallback(() => { setProviderSourcesRevision((revision) => revision + 1); @@ -534,7 +536,10 @@ export const ProvidersPage: React.FC = () => { setEditingCustomScope(null); setCustomAuthFailureHint(null); setLastCustomPersistId(null); - await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' }); + // Mutation succeeded; route through the helper so an externally managed + // OpenCode does not produce a misleading "save failed" toast for a write + // that already persisted. + await applyConfigReloadOrRecordDeferred('providers', plan.providerID); markAuthWriteSucceeded(plan.providerID); } catch (error) { console.error('Failed to save custom provider:', error); @@ -556,6 +561,9 @@ export const ProvidersPage: React.FC = () => { if (requiresOpenCodeRestartAfterOAuth(providerId)) { recordDeferredOpenCodeRestart('providers', { id: providerId }); } + // Optimistic mark + sources refetch so the page does not stick on a stale + // "Credentials missing" summary while the providers refresh lands. + markAuthWriteSucceeded(providerId); }; const handleDisconnectProvider = async (providerId: string) => { @@ -579,7 +587,10 @@ export const ProvidersPage: React.FC = () => { toast.success(t('settings.providers.page.toast.providerDisconnected')); // Only accumulate when the server actually deferred a restart (e.g. auth removed). // removed:false payloads must not create a phantom pending Apply & Restart. - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + // Use the helper so an externally managed OpenCode that requires a manual + // restart records the deferred-restart guidance instead of toasting a + // misleading "disconnect failed" for a write that already persisted. + await applyConfigReloadOrRecordDeferred('providers', providerId); setAuthPanelDismissedForId(null); refreshProviderSources(); } catch (error) { diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index 82a77825..f57d744e 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -89,4 +89,17 @@ export const shouldAutoOpenAuthPanel = (input: { sourcesLoaded: boolean; hasCredentials: boolean; userDismissed: boolean; -}): boolean => input.sourcesLoaded && !input.hasCredentials && !input.userDismissed; + /** + * Config-defined custom providers (providerSources.custom present and parsed + * via `isConfigDefinedCustomProvider`) do not auto-open the auth panel: the + * provider is editable directly in the form, and a stale `Credentials + * missing` summary would be misleading. Optional for back-compat; defaults to + * `false`, restoring the pre-rewrite exemption that `requiresProviderAuth` + * carried via `providerAvailability.ts`. + */ + isEditableCustomProvider?: boolean; +}): boolean => + input.sourcesLoaded && + !input.hasCredentials && + !input.userDismissed && + !input.isEditableCustomProvider; From 75d93f79c074fa93e13de7998207c86832763712 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 28 Aug 2026 16:59:52 +0200 Subject: [PATCH 6/9] fix(providers): treat options.apiKey as a credential in credential signal The previous review flagged that providerHasCredentials misclassifies working config-defined providers: Provider.key is only set by upstream when exactly one declared env var resolves or an api-type auth.json entry exists. Config providers only get options, so provider.. options.apiKey never reaches key. Result: a provider whose key is embedded in opencode.json showed 'Credentials missing', lost the Models section, and forced the auth panel open. Add optionsApiKey to ProviderCredentialInput and check it in providerHasCredentials alongside key and authSourceExists. This matches what main's requiresProviderAuth helper used to do and honors the OpenChamber docs contract that options.apiKey counts as a usable login (walkthrough/DOCUMENTATION.md:134). Wire the new field through ProvidersPage.tsx using a typed indirection: the SDK Provider type does not yet expose options publicly, so the read site casts the object to the known shape. This keeps the call site type-safe without waiting for an SDK update. --- .../sections/providers/ProvidersPage.test.ts | 9 +++++++++ .../components/sections/providers/ProvidersPage.tsx | 1 + .../src/components/sections/providers/providerAuth.ts | 11 +++++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index 4445ca78..5bb461f3 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -89,6 +89,15 @@ describe('provider credential state helpers', () => { expect(providerHasCredentials({ key: undefined, authSourceExists: true })).toBe(true); }); + test('providerHasCredentials treats options.apiKey as a usable credential', () => { + // Config-defined providers ship provider.options to the client but never + // reach Provider.key, so the only authoritative signal is options.apiKey. + expect(providerHasCredentials({ key: undefined, authSourceExists: false, optionsApiKey: 'sk-config' })).toBe(true); + expect(providerHasCredentials({ key: undefined, authSourceExists: false, optionsApiKey: '' })).toBe(false); + expect(providerHasCredentials({ key: undefined, authSourceExists: false, optionsApiKey: ' ' })).toBe(false); + expect(providerHasCredentials({ key: undefined, authSourceExists: false, optionsApiKey: null })).toBe(false); + }); + test('env-less OAuth-only provider without credentials opens panel and hides models', () => { const hasCredentials = providerHasCredentials({ key: undefined, diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index e6f35a0c..18269918 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -860,6 +860,7 @@ export const ProvidersPage: React.FC = () => { const hasCredentials = providerHasCredentials({ key: selectedProvider.key, authSourceExists: selectedSources?.auth.exists, + optionsApiKey: (selectedProvider as { options?: { apiKey?: string | null } }).options?.apiKey ?? null, }); const authStatusIncomplete = sourcesLoaded && !hasCredentials; const showModelsSection = shouldShowModelsSection({ diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index f57d744e..7cc7b5ac 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -66,6 +66,14 @@ export interface ProviderCredentialInput { key?: string | null; /** OpenChamber auth.json provenance for this provider. */ authSourceExists?: boolean | null; + /** + * Provider.options is shipped to the client for config-defined providers + * but never reaches `Provider.key` (upstream only sets `key` from a single + * resolved env var or an api-type auth.json entry). Treat a non-empty + * `options.apiKey` as a usable login, per + * `packages/web/server/lib/walkthrough/DOCUMENTATION.md:134`. + */ + optionsApiKey?: string | null; } /** @@ -76,6 +84,9 @@ export const providerHasCredentials = (input: ProviderCredentialInput): boolean if (typeof input.key === 'string' && input.key.trim().length > 0) { return true; } + if (typeof input.optionsApiKey === 'string' && input.optionsApiKey.trim().length > 0) { + return true; + } return input.authSourceExists === true; }; From 65668f1453946e537a4c34ff9f62a57aaeeb4aa3 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 28 Aug 2026 17:47:34 +0200 Subject: [PATCH 7/9] fix(providers): wire optionsApiKey through both call sites; restore editable custom exemption The previous fix added optionsApiKey to providerHasCredentials but wired it through only one of the two call sites in ProvidersPage.tsx. The auto-open effect at line 339 still omitted it, so a config-defined provider whose only credential is options.apiKey would get the auth panel force-opened on every selection while the summary beside it said Connected; the dismissal resets on provider switch, so the panel re-opens each time. Restore the isEditableCustomProvider exemption that main's requiresProviderAuth helper carried into both authStatusIncomplete and shouldShowModelsSection. A keyless local custom provider (LM Studio / Ollama style) regressed from 'models visible, no banner' to 'Credentials missing' with the models section hidden. Rewire requiresProviderAuth (its only production consumer was lost in the rebase) by delegating authStatusIncomplete to it. The helper already encodes sourcesLoaded && !hasCredentials && !isEditableCustom Provider, so the call site is one line and the contract matches main. shouldShowModelsSection now accepts an optional isEditableCustom Provider flag that lifts the credential gate for editable providers, matching the exemption the ProvidersPage.test.ts:22 fixture asserts. Tests: 15/15 pass. Was 14 in the previous commit; added one test for the editable custom exemption. --- .../sections/providers/ProvidersPage.test.ts | 25 +++++++++++++++++++ .../sections/providers/ProvidersPage.tsx | 6 +++-- .../sections/providers/providerAuth.ts | 12 ++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index 5bb461f3..1861568c 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -133,6 +133,31 @@ describe('provider credential state helpers', () => { })).toBe(true); }); + test('editable custom provider keeps models visible even with no credentials signal', () => { + // Config-defined custom providers (e.g. local LM Studio/Ollama style) + // are user-editable in place; a stale 'Credentials missing' must not + // hide their models section. Without the exemption, a keyless local + // custom provider regresses to 'Credentials missing' with models hidden. + const hasCredentials = providerHasCredentials({ + key: undefined, + authSourceExists: false, + optionsApiKey: null, + }); + expect(hasCredentials).toBe(false); + expect(shouldShowModelsSection({ + modelCount: 1, + sourcesLoaded: true, + hasCredentials: false, + isEditableCustomProvider: true, + })).toBe(true); + expect(shouldShowModelsSection({ + modelCount: 1, + sourcesLoaded: true, + hasCredentials: false, + isEditableCustomProvider: false, + })).toBe(false); + }); + test('auth save followed by providers refresh recognizes credentials without stale missing state', () => { // Pre-save: sources say no auth, provider has no key yet. const before = providerHasCredentials({ diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 18269918..d50b4875 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -27,7 +27,7 @@ import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { opencodeClient } from '@/lib/opencode/client'; -import { shouldLoadAvailableProviders } from './providerAvailability'; +import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability'; import { getOAuthAuthMethods, parseAuthPayload, @@ -339,6 +339,7 @@ export const ProvidersPage: React.FC = () => { const hasCreds = providerHasCredentials({ key: provider?.key, authSourceExists: sources.auth.exists, + optionsApiKey: (provider as { options?: { apiKey?: string | null } } | undefined)?.options?.apiKey ?? null, }); const isEditableCustomProvider = Boolean( provider && isConfigDefinedCustomProvider(provider, sources) @@ -862,11 +863,12 @@ export const ProvidersPage: React.FC = () => { authSourceExists: selectedSources?.auth.exists, optionsApiKey: (selectedProvider as { options?: { apiKey?: string | null } }).options?.apiKey ?? null, }); - const authStatusIncomplete = sourcesLoaded && !hasCredentials; + const authStatusIncomplete = requiresProviderAuth(sourcesLoaded, hasCredentials, isEditableCustomProvider); const showModelsSection = shouldShowModelsSection({ modelCount: providerModels.length, sourcesLoaded, hasCredentials, + isEditableCustomProvider, }); const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0 ? t('settings.providers.page.auth.useReconnectHint') diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index 7cc7b5ac..cff0ca5b 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -94,7 +94,17 @@ export const shouldShowModelsSection = (input: { modelCount: number; sourcesLoaded: boolean; hasCredentials: boolean; -}): boolean => input.modelCount > 0 && (!input.sourcesLoaded || input.hasCredentials); + /** + * Config-defined custom providers (providerSources.custom present and parsed + * via `isConfigDefinedCustomProvider`) are user-editable in place, so a + * stale `Credentials missing` signal must not hide their models section. + * Optional for back-compat; defaults to `false`, restoring the pre-rewrite + * exemption that `requiresProviderAuth` carried via `providerAvailability.ts`. + */ + isEditableCustomProvider?: boolean; +}): boolean => + input.modelCount > 0 && + (!input.sourcesLoaded || input.hasCredentials || Boolean(input.isEditableCustomProvider)); export const shouldAutoOpenAuthPanel = (input: { sourcesLoaded: boolean; From 7d20f2897a025970d9b1178e67be0bab0a13f796 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 28 Aug 2026 18:22:30 +0200 Subject: [PATCH 8/9] docs(providers): drop stale disconnect comment; rewrite changelog bullet Two nits from the openchamber-bot review at 65668f14: - The disconnect handler carried a stale comment that referenced a "removed:false payload gating" feature the rebase removed; the helper below it does not do that gating. Drop the comment. - The Hide button onClick ran setAuthPanelDismissedForId inside the setShowAuthPanel updater (idempotent today, impure under StrictMode double-invoke). Move both calls outside the updater. Also rewrite the [Unreleased] changelog bullet to describe the user-visible change (Connected + models visible for options.apiKey providers) instead of internal mechanics ("source refetch + optimistic auth mark"), and broaden the wording from "after saving" to cover OAuth, custom-provider, and disconnect paths. --- CHANGELOG.md | 2 +- .../sections/providers/ProvidersPage.tsx | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc6d808..32f2248a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). - Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. -- Providers: after saving a provider's credentials the settings page now refreshes the credential state (source refetch + optimistic auth mark) instead of keeping the stale "Credentials missing" / empty-models summary until the next restart (thanks to @herjarsa). +- Providers: providers whose credential lives in `provider..options.apiKey` now show as Connected with their models visible, instead of "Credentials missing" plus a forced auth panel; covers OAuth, custom-provider, and disconnect paths (thanks to @herjarsa). - Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. ## [1.21.0] - 2026-08-26 diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index d50b4875..7dc52592 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -586,8 +586,6 @@ export const ProvidersPage: React.FC = () => { } toast.success(t('settings.providers.page.toast.providerDisconnected')); - // Only accumulate when the server actually deferred a restart (e.g. auth removed). - // removed:false payloads must not create a phantom pending Apply & Restart. // Use the helper so an externally managed OpenCode that requires a manual // restart records the deferred-restart guidance instead of toasting a // misleading "disconnect failed" for a write that already persisted. @@ -940,15 +938,9 @@ export const ProvidersPage: React.FC = () => { size="xs" className="!font-normal" onClick={() => { - setShowAuthPanel((prev) => { - const next = !prev; - if (!next) { - setAuthPanelDismissedForId(selectedProvider.id); - } else { - setAuthPanelDismissedForId(null); - } - return next; - }); + const nextOpen = !showAuthPanel; + setShowAuthPanel(nextOpen); + setAuthPanelDismissedForId(nextOpen ? null : selectedProvider.id); }} > {showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')} From c788cd22376a486e32abae7b74c0df7054239cc5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 29 Aug 2026 00:50:49 +0300 Subject: [PATCH 9/9] fix(providers): keep declared env vars as a credential signal Providers that read credentials from several environment variables (Bedrock, Azure, Vertex) never get a single resolved Provider.key from OpenCode, so dropping the env signal made them show "Credentials missing", auto-open the auth panel, and hide their models even when fully configured. providerHasCredentials takes an envDeclared input again, and both call sites in ProvidersPage pass whether the provider declares any non-empty env var name. --- .../sections/providers/ProvidersPage.test.ts | 12 ++++++++++-- .../sections/providers/ProvidersPage.tsx | 10 ++++++++++ .../components/sections/providers/providerAuth.ts | 15 +++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index 1861568c..25d4dbcc 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -77,8 +77,8 @@ describe('provider auth method helpers', () => { }); describe('provider credential state helpers', () => { - test('providerHasCredentials ignores declared env names and requires key or auth source', () => { - // Built-in catalog entry with env var names but no actual credential. + test('providerHasCredentials requires key, options.apiKey, declared env, or auth source', () => { + // Built-in catalog entry with no credential signal at all. expect(providerHasCredentials({ key: undefined, authSourceExists: false })).toBe(false); expect(providerHasCredentials({ key: '', authSourceExists: false })).toBe(false); expect(providerHasCredentials({ key: ' ', authSourceExists: false })).toBe(false); @@ -89,6 +89,14 @@ describe('provider credential state helpers', () => { expect(providerHasCredentials({ key: undefined, authSourceExists: true })).toBe(true); }); + test('providerHasCredentials counts declared env vars for multi-variable providers', () => { + // Bedrock/Azure/Vertex resolve credentials from several env vars, so + // OpenCode never sets Provider.key for them; the declared env list is the + // only signal that the provider is configured. + expect(providerHasCredentials({ key: undefined, authSourceExists: false, envDeclared: true })).toBe(true); + expect(providerHasCredentials({ key: undefined, authSourceExists: false, envDeclared: false })).toBe(false); + }); + test('providerHasCredentials treats options.apiKey as a usable credential', () => { // Config-defined providers ship provider.options to the client but never // reach Provider.key, so the only authoritative signal is options.apiKey. diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 7dc52592..32a819ce 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -53,6 +53,14 @@ import { type ProviderConfigScope, } from './custom-provider-form'; +/** + * Providers whose credentials come from several env vars (Bedrock, Azure, + * Vertex) never get a single resolved `Provider.key` from OpenCode, so the + * declared env list is the only signal that they are configured at all. + */ +const providerDeclaresEnv = (provider: { env?: string[] } | undefined): boolean => + Array.isArray(provider?.env) && provider.env.some((name) => name.trim().length > 0); + const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), { notation: 'compact', compactDisplay: 'short', @@ -340,6 +348,7 @@ export const ProvidersPage: React.FC = () => { key: provider?.key, authSourceExists: sources.auth.exists, optionsApiKey: (provider as { options?: { apiKey?: string | null } } | undefined)?.options?.apiKey ?? null, + envDeclared: providerDeclaresEnv(provider), }); const isEditableCustomProvider = Boolean( provider && isConfigDefinedCustomProvider(provider, sources) @@ -860,6 +869,7 @@ export const ProvidersPage: React.FC = () => { key: selectedProvider.key, authSourceExists: selectedSources?.auth.exists, optionsApiKey: (selectedProvider as { options?: { apiKey?: string | null } }).options?.apiKey ?? null, + envDeclared: providerDeclaresEnv(selectedProvider), }); const authStatusIncomplete = requiresProviderAuth(sourcesLoaded, hasCredentials, isEditableCustomProvider); const showModelsSection = shouldShowModelsSection({ diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index cff0ca5b..61a570f4 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -74,11 +74,19 @@ export interface ProviderCredentialInput { * `packages/web/server/lib/walkthrough/DOCUMENTATION.md:134`. */ optionsApiKey?: string | null; + /** + * The provider declares environment variables it reads credentials from. + * Multi-variable providers (Bedrock, Azure, Vertex) never resolve a single + * `Provider.key` upstream, so without this signal they read as + * "Credentials missing" even when fully configured. + */ + envDeclared?: boolean; } /** - * Prefer authoritative credential signals. Do not treat Provider.env length as - * proof of credentials — that array is declared env var *names*, not values. + * Prefer authoritative credential signals. Declared env vars are the weakest of + * them — the array holds variable *names*, not values — but for providers whose + * credentials span several env vars it is the only signal OpenCode exposes. */ export const providerHasCredentials = (input: ProviderCredentialInput): boolean => { if (typeof input.key === 'string' && input.key.trim().length > 0) { @@ -87,6 +95,9 @@ export const providerHasCredentials = (input: ProviderCredentialInput): boolean if (typeof input.optionsApiKey === 'string' && input.optionsApiKey.trim().length > 0) { return true; } + if (input.envDeclared === true) { + return true; + } return input.authSourceExists === true; };