fix: integrate Claude CLI provider state

This commit is contained in:
Bohdan Triapitsyn
2026-08-15 01:59:26 +03:00
parent 72cd861eb8
commit d353638f39
17 changed files with 229 additions and 21 deletions
@@ -52,7 +52,7 @@ export const DefaultsSettings: React.FC = () => {
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[]>([]);
const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
@@ -274,13 +274,9 @@ export const DefaultsSettings: React.FC = () => {
() => getDisplayModel(walkthroughModelOverride),
[walkthroughModelOverride]
);
React.useEffect(() => {
// Both pickers filter by the same authenticated-provider list, so either
// one being open is reason enough to fetch it.
// Both pickers filter by the same authenticated-provider list, and the
// walkthrough picker is always visible, so this is always worth fetching.
if (smallModelProviders !== undefined) return;
let cancelled = false;
(async () => {
try {
@@ -291,13 +287,13 @@ export const DefaultsSettings: React.FC = () => {
setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
}
} catch {
// leave undefined — picker falls back to showing all providers
// Fail closed: never offer providers whose credentials were not verified.
}
})();
return () => {
cancelled = true;
};
}, [smallModelProviders]);
}, []);
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
@@ -20,6 +20,7 @@ import {
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
@@ -173,7 +174,10 @@ export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
return;
}
if (authorization.url) {
// Claude Code CLI owns its OAuth flow and opens the browser itself. Its
// plugin URL is informational only; opening it creates a misleading docs
// tab alongside the real sign-in page.
if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) {
void openExternalUrl(authorization.url);
}
@@ -4,6 +4,7 @@ import {
getOAuthAuthMethods,
normalizeAuthType,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
} from './providerAuth';
@@ -57,4 +58,9 @@ describe('provider auth method helpers', () => {
{ method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 },
]);
});
test('Claude CLI OAuth does not require an OpenCode restart', () => {
expect(requiresOpenCodeRestartAfterOAuth('claude-code')).toBe(false);
expect(requiresOpenCodeRestartAfterOAuth('github-copilot')).toBe(true);
});
});
@@ -27,6 +27,7 @@ import { shouldLoadAvailableProviders } from './providerAvailability';
import {
getOAuthAuthMethods,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
type AuthMethod,
type OAuthAuthMethodEntry,
@@ -471,7 +472,9 @@ export const ProvidersPage: React.FC = () => {
const handleOAuthConnected = (providerId: string) => {
setShowAuthPanel(false);
recordDeferredOpenCodeRestart('providers', { id: providerId });
if (requiresOpenCodeRestartAfterOAuth(providerId)) {
recordDeferredOpenCodeRestart('providers', { id: providerId });
}
setSelectedProvider(providerId);
};
@@ -7,11 +7,19 @@ import {
isPromptVisible,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type ProviderOAuthTranslator,
} from './provider-oauth';
describe('shouldOpenAuthorizationUrl', () => {
test('lets Claude Code CLI own browser launch', () => {
expect(shouldOpenAuthorizationUrl('claude-code', 'https://docs.example')).toBe(false);
expect(shouldOpenAuthorizationUrl('github-copilot', 'https://github.com/login')).toBe(true);
});
});
/** Mirrors the github-copilot auth method shipped by OpenCode. */
const copilotPrompts = [
{
@@ -29,6 +29,9 @@ export interface OAuthAuthorization {
userCode?: string;
}
export const shouldOpenAuthorizationUrl = (providerId: string, url?: string): boolean =>
Boolean(url) && providerId !== 'claude-code';
export interface AuthPromptOption {
label: string;
value: string;
@@ -57,3 +57,6 @@ export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry
methods
.map((method, methodIndex) => ({ method, methodIndex }))
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
export const requiresOpenCodeRestartAfterOAuth = (providerId: string): boolean =>
providerId !== 'claude-code';
+2 -2
View File
@@ -12,7 +12,7 @@ import { useSayTTS } from './useSayTTS';
import { useLocalTTS } from './useLocalTTS';
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { sanitizeForTTS } from '@/lib/voice/summarize';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { requestSmallModel } from '@/lib/smallModelRequest';
// Below this length the reply is comfortable to listen to as-is; summarizing
// would only add latency.
@@ -25,7 +25,7 @@ async function summarizeForSpeech(
preferred: { providerID?: string; modelID?: string },
): Promise<string | null> {
try {
const response = await runtimeFetch('/api/small-model/generate', {
const response = await requestSmallModel({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+5 -4
View File
@@ -3,6 +3,7 @@ import * as gitHttp from './gitApiHttp';
import { opencodeClient } from './opencode/client';
import { renderMagicPrompt } from './magicPrompts';
import { runtimeFetch } from './runtime-fetch';
import { requestSmallModel } from './smallModelRequest';
import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -283,7 +284,7 @@ export async function generateCommitMessage(
try {
const diffs = await collectSelectedFileDiffs(directory, files);
const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', {
const response = await requestSmallModel({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -293,7 +294,7 @@ export async function generateCommitMessage(
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}),
}),
});
}, { silentStatuses: [404] });
if (response.status === 404) {
// No authenticated provider has a small model — fall back to the
@@ -411,7 +412,7 @@ export async function generatePullRequestDescription(
try {
const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', {
const response = await requestSmallModel({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -421,7 +422,7 @@ export async function generatePullRequestDescription(
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}),
}),
});
}, { silentStatuses: [404] });
if (response.status === 404) {
// No authenticated provider has a small model — fall back to the
+3 -3
View File
@@ -1,4 +1,4 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { requestSmallModel } from '@/lib/smallModelRequest';
import { useConfigStore } from '@/stores/useConfigStore';
import { getSessionLastAssistantModel } from '@/sync/session-actions';
@@ -34,7 +34,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
const { currentProviderId, currentModelId } = useConfigStore.getState();
const preferredProviderID = sessionModel?.providerID || currentProviderId || '';
const preferredModelID = sessionModel?.modelID || currentModelId || '';
const response = await runtimeFetch('/api/small-model/generate', {
const response = await requestSmallModel({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -77,7 +77,7 @@ const GOAL_OBJECTIVE_SYSTEM_PROMPT = [
export async function distillGoalObjective(planContent: string): Promise<string | null> {
try {
const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', {
const response = await requestSmallModel({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+27
View File
@@ -0,0 +1,27 @@
import { toast } from 'sonner';
import { runtimeFetch } from '@/lib/runtime-fetch';
const SMALL_MODEL_TOAST_ID = 'small-model-unavailable';
const notifySmallModelUnavailable = (): void => {
toast.error('Small Model unavailable', {
id: SMALL_MODEL_TOAST_ID,
description: 'Choose another model in Settings → Sessions → Small Model and try again.',
});
};
export async function requestSmallModel(
init: RequestInit,
options: { silentStatuses?: number[] } = {},
): Promise<Response> {
try {
const response = await runtimeFetch('/api/small-model/generate', init);
if (!response.ok && !options.silentStatuses?.includes(response.status)) {
notifySmallModelUnavailable();
}
return response;
} catch (error) {
notifySmallModelUnavailable();
throw error;
}
}