fix: integrate Claude CLI provider state
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const readStatus = (spawnSyncFn, command, env) => spawnSyncFn(command, ['auth', 'status', '--json'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 6000,
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const resolveFromLoginShell = (spawnSyncFn, env, platform) => {
|
||||
if (platform === 'win32') {
|
||||
const result = spawnSyncFn('where', ['claude'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 6000,
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return `${result.stdout || ''}`.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || null;
|
||||
}
|
||||
|
||||
const shell = env.SHELL || '/bin/zsh';
|
||||
const result = spawnSyncFn(shell, ['-lic', 'command -v claude'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 6000,
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return `${result.stdout || ''}`.trim().split(/\s+/).pop() || null;
|
||||
};
|
||||
|
||||
export const getClaudeCliAuthStatus = ({
|
||||
spawnSyncFn = spawnSync,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
} = {}) => {
|
||||
const childEnv = { ...env };
|
||||
delete childEnv.ANTHROPIC_API_KEY;
|
||||
delete childEnv.ANTHROPIC_AUTH_TOKEN;
|
||||
delete childEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
try {
|
||||
let result = readStatus(spawnSyncFn, 'claude', childEnv);
|
||||
if (!`${result.stdout || ''}`.trim() && result.error) {
|
||||
const resolved = resolveFromLoginShell(spawnSyncFn, childEnv, platform);
|
||||
if (resolved) result = readStatus(spawnSyncFn, resolved, childEnv);
|
||||
}
|
||||
const output = `${result.stdout || ''}`.trim();
|
||||
if (!output) return { connected: false, reason: 'empty-status' };
|
||||
const payload = JSON.parse(output);
|
||||
return {
|
||||
connected: payload?.loggedIn === true,
|
||||
reason: payload?.loggedIn === true ? 'logged-in' : 'logged-out',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
connected: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
||||
|
||||
describe('getClaudeCliAuthStatus', () => {
|
||||
test('reports the authoritative Claude CLI login state', () => {
|
||||
let invocation = null;
|
||||
const status = getClaudeCliAuthStatus({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'must-not-leak',
|
||||
},
|
||||
spawnSyncFn(command, args, options) {
|
||||
invocation = { command, args, options };
|
||||
return { stdout: JSON.stringify({ loggedIn: true, authMethod: 'oauth' }) };
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual({ connected: true, reason: 'logged-in' });
|
||||
expect(invocation.command).toBe('claude');
|
||||
expect(invocation.args).toEqual(['auth', 'status', '--json']);
|
||||
expect(invocation.options.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ignores a stale OpenCode marker when the CLI is logged out', () => {
|
||||
const status = getClaudeCliAuthStatus({
|
||||
spawnSyncFn: () => ({ stdout: JSON.stringify({ loggedIn: false }) }),
|
||||
});
|
||||
|
||||
expect(status).toEqual({ connected: false, reason: 'logged-out' });
|
||||
});
|
||||
|
||||
test('finds Claude through a login shell when a desktop PATH cannot', () => {
|
||||
const invocations = [];
|
||||
const status = getClaudeCliAuthStatus({
|
||||
env: { HOME: '/Users/test', PATH: '/usr/bin:/bin', SHELL: '/bin/zsh' },
|
||||
platform: 'darwin',
|
||||
spawnSyncFn(command, args, options) {
|
||||
invocations.push({ command, args, options });
|
||||
if (command === 'claude') return { stdout: '', error: new Error('spawnSync claude ENOENT') };
|
||||
if (command === '/bin/zsh') return { stdout: '/Users/test/.local/bin/claude\n' };
|
||||
return { stdout: JSON.stringify({ loggedIn: true, authMethod: 'claude.ai' }) };
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual({ connected: true, reason: 'logged-in' });
|
||||
expect(invocations.map(({ command }) => command)).toEqual([
|
||||
'claude',
|
||||
'/bin/zsh',
|
||||
'/Users/test/.local/bin/claude',
|
||||
]);
|
||||
expect(invocations[1].args).toEqual(['-lic', 'command -v claude']);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
import {
|
||||
buildDeferredRestartResponse,
|
||||
} from './config-mutation-response.js';
|
||||
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
||||
|
||||
export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
const {
|
||||
@@ -568,7 +569,9 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
||||
const sources = getProviderSources(providerId, directory);
|
||||
const { getProviderAuth } = await getAuthLibrary();
|
||||
const auth = getProviderAuth(providerId);
|
||||
sources.sources.auth.exists = Boolean(auth);
|
||||
sources.sources.auth.exists = providerId === 'claude-code'
|
||||
? getClaudeCliAuthStatus().connected
|
||||
: Boolean(auth);
|
||||
|
||||
return res.json({
|
||||
providerId,
|
||||
|
||||
@@ -120,6 +120,13 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved.providerID === 'claude-code') {
|
||||
throw Object.assign(
|
||||
new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'),
|
||||
{ statusCode: 422, code: 'small-model-provider-unsupported' },
|
||||
);
|
||||
}
|
||||
|
||||
// Callers with a session context can forbid silently switching providers:
|
||||
// an explicit user choice (settings override, opencode config, request
|
||||
// model) is always allowed, anything else must stay on the session's
|
||||
@@ -183,6 +190,7 @@ export function listAuthenticatedProviders() {
|
||||
const ids = new Set(
|
||||
Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])),
|
||||
);
|
||||
ids.delete('claude-code');
|
||||
// The catalog id is github-copilot while legacy auth entries may sit
|
||||
// under the copilot alias.
|
||||
if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
|
||||
|
||||
@@ -26,12 +26,42 @@ vi.mock('./call.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { generateSmallModelText, describeSmallModel } = await import('./index.js');
|
||||
const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
|
||||
const { readAuthFile } = await import('../opencode/auth.js');
|
||||
const { readConfigLayers } = await import('../opencode/shared.js');
|
||||
const { getModelCatalog } = await import('./catalog.js');
|
||||
const { callSmallModel } = await import('./call.js');
|
||||
|
||||
describe('unsupported small-model providers', () => {
|
||||
beforeEach(() => {
|
||||
readAuthFile.mockReturnValue({
|
||||
'claude-code': {
|
||||
type: 'oauth',
|
||||
access: 'claude-cli-managed',
|
||||
refresh: 'claude-cli-managed',
|
||||
},
|
||||
});
|
||||
readConfigLayers.mockReturnValue({ mergedConfig: {} });
|
||||
getModelCatalog.mockResolvedValue({});
|
||||
callSmallModel.mockReset();
|
||||
});
|
||||
|
||||
it('rejects Claude Code with an actionable error before transport dispatch', async () => {
|
||||
await expect(generateSmallModelText({
|
||||
prompt: 'summarize this',
|
||||
model: 'claude-code/haiku',
|
||||
})).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
code: 'small-model-provider-unsupported',
|
||||
});
|
||||
expect(callSmallModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not offer Claude Code in the Small Model picker', () => {
|
||||
expect(listAuthenticatedProviders()).not.toContain('claude-code');
|
||||
});
|
||||
});
|
||||
|
||||
// 8k context leaves 4k input tokens after the output reserve → 16k chars.
|
||||
const CATALOG = {
|
||||
anthropic: {
|
||||
|
||||
@@ -39,7 +39,9 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
|
||||
console.error('Small model generation failed:', error);
|
||||
}
|
||||
res.status(statusCode).json({
|
||||
error: error.message || 'Small model generation failed',
|
||||
error: statusCode === 404
|
||||
? (error.message || 'No small model is available')
|
||||
: 'The selected Small Model could not complete this action. Choose another model in Settings → Sessions → Small Model and try again.',
|
||||
...(error?.code ? { code: error.code } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user