fix(web): clarify finalize BYOK requirements for Local CLI users (#3041)

Local CLI chat does not supply BYOK credentials to finalize synthesis.
Resolve per-protocol saved settings before calling the daemon and show an
actionable toast instead of a generic BAD_REQUEST when credentials are
missing.

Fixes #2959
This commit is contained in:
吴杨帆 2026-05-27 14:20:33 +08:00 committed by GitHub
parent f44a5d5816
commit 582a03195f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 170 additions and 12 deletions

View file

@ -164,7 +164,10 @@ import { useTerminalLaunch } from '../hooks/useTerminalLaunch';
import { buildContinueInCliToast } from '../lib/build-continue-in-cli-toast';
import { buildClipboardPrompt } from '../lib/build-clipboard-prompt';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { effectiveMaxTokens } from '../state/maxTokens';
import {
buildFinalizeCredentialsMissingToast,
buildFinalizeRequest,
} from '../lib/resolve-finalize-request';
type ProjectChatSendMeta = ChatSendMeta & {
retryOfAssistantId?: string;
@ -3916,17 +3919,12 @@ export function ProjectView({
// shortcut wiring. Close to the JSX so the data flow is easy to
// trace from the toolbar back to its sources.
const handleFinalize = useCallback(() => {
const protocol = config.apiProtocol ?? 'anthropic';
void finalize.trigger({
protocol,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
model: config.model,
maxTokens: effectiveMaxTokens(config),
...(protocol === 'azure' && config.apiVersion?.trim()
? { apiVersion: config.apiVersion.trim() }
: {}),
}).then((result) => {
const request = buildFinalizeRequest(config);
if (!request) {
setProjectActionsToast(buildFinalizeCredentialsMissingToast(config));
return;
}
void finalize.trigger(request).then((result) => {
if (result) void designMdState.refresh();
});
}, [finalize, config, designMdState]);

View file

@ -0,0 +1,81 @@
import type {
FinalizeAnthropicRequest,
FinalizeProviderProtocol,
} from '@open-design/contracts';
import { effectiveMaxTokens } from '../state/maxTokens';
import type { ApiProtocol, AppConfig } from '../types';
const FINALIZE_PROTOCOLS = new Set<FinalizeProviderProtocol>([
'anthropic',
'openai',
'azure',
'google',
'ollama',
]);
export interface FinalizeCredentialsMissingToast {
message: string;
details: string | null;
}
function resolveFinalizeProtocol(config: AppConfig): FinalizeProviderProtocol {
const protocol = config.apiProtocol ?? 'anthropic';
return FINALIZE_PROTOCOLS.has(protocol as FinalizeProviderProtocol)
? (protocol as FinalizeProviderProtocol)
: 'anthropic';
}
function resolveByokFields(config: AppConfig, protocol: ApiProtocol) {
const saved = config.apiProtocolConfigs?.[protocol];
return {
apiKey: (saved?.apiKey ?? config.apiKey ?? '').trim(),
baseUrl: (saved?.baseUrl ?? config.baseUrl ?? '').trim(),
model: (saved?.model ?? config.model ?? '').trim(),
apiVersion: (saved?.apiVersion ?? config.apiVersion ?? '').trim(),
};
}
export function isFinalizeByokConfigured(config: AppConfig): boolean {
const protocol = resolveFinalizeProtocol(config);
const { apiKey, model } = resolveByokFields(config, protocol);
return Boolean(apiKey && model);
}
export function buildFinalizeRequest(
config: AppConfig,
): FinalizeAnthropicRequest | null {
const protocol = resolveFinalizeProtocol(config);
const { apiKey, baseUrl, model, apiVersion } = resolveByokFields(
config,
protocol,
);
if (!apiKey || !model) return null;
return {
protocol,
apiKey,
...(baseUrl ? { baseUrl } : {}),
model,
maxTokens: effectiveMaxTokens(config),
...(protocol === 'azure' && apiVersion ? { apiVersion } : {}),
};
}
export function buildFinalizeCredentialsMissingToast(
config: AppConfig,
): FinalizeCredentialsMissingToast {
if (config.mode === 'daemon') {
return {
message:
'Finalize design package needs BYOK API settings — Local CLI login is used for chat only.',
details:
'Open Settings → BYOK to add an API key and model, or use Continue in CLI (⌘⇧K) to finalize manually.',
};
}
return {
message: 'Bad request — check the API key and model.',
details: 'Open Settings → BYOK and verify your API key, base URL, and model.',
};
}

View file

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
buildFinalizeCredentialsMissingToast,
buildFinalizeRequest,
isFinalizeByokConfigured,
} from '../../src/lib/resolve-finalize-request';
import { DEFAULT_CONFIG } from '../../src/state/config';
describe('resolve-finalize-request', () => {
it('returns null when BYOK credentials are missing', () => {
expect(
buildFinalizeRequest({
...DEFAULT_CONFIG,
mode: 'daemon',
apiKey: '',
model: 'claude-sonnet-4-5',
}),
).toBeNull();
expect(isFinalizeByokConfigured(DEFAULT_CONFIG)).toBe(false);
});
it('resolves credentials from apiProtocolConfigs when top-level fields are empty', () => {
const request = buildFinalizeRequest({
...DEFAULT_CONFIG,
mode: 'daemon',
apiProtocol: 'google',
apiKey: '',
baseUrl: '',
model: '',
apiProtocolConfigs: {
google: {
apiKey: 'google-key',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.5-pro',
},
},
});
expect(request).toMatchObject({
protocol: 'google',
apiKey: 'google-key',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.5-pro',
});
expect(isFinalizeByokConfigured({
...DEFAULT_CONFIG,
mode: 'daemon',
apiProtocol: 'google',
apiKey: '',
model: '',
apiProtocolConfigs: {
google: {
apiKey: 'google-key',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.5-pro',
},
},
})).toBe(true);
});
it('surfaces a Local CLI-specific toast when finalize lacks BYOK settings', () => {
expect(
buildFinalizeCredentialsMissingToast({
...DEFAULT_CONFIG,
mode: 'daemon',
}).message,
).toContain('Local CLI login is used for chat only');
});
it('surfaces the generic BYOK toast when execution mode is api', () => {
expect(
buildFinalizeCredentialsMissingToast({
...DEFAULT_CONFIG,
mode: 'api',
}).message,
).toBe('Bad request — check the API key and model.');
});
});