feat: gate model overrides and load full token history (#271)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-04 11:52:54 +08:00
committed by GitHub
Unverified
parent 3371e4fe74
commit 30b03add1c
12 changed files with 181 additions and 33 deletions

View File

@@ -1882,8 +1882,8 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
function registerUsageHandlers(): void { function registerUsageHandlers(): void {
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => { ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
const safeLimit = typeof limit === 'number' && Number.isFinite(limit) const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
? Math.min(Math.max(Math.floor(limit), 1), 100) ? Math.max(Math.floor(limit), 1)
: 20; : undefined;
return await getRecentTokenUsageHistory(safeLimit); return await getRecentTokenUsageHistory(safeLimit);
}); });
} }

View File

@@ -41,12 +41,15 @@ interface TranscriptLineShape {
export function parseUsageEntriesFromJsonl( export function parseUsageEntriesFromJsonl(
content: string, content: string,
context: { sessionId: string; agentId: string }, context: { sessionId: string; agentId: string },
limit = 20, limit?: number,
): TokenUsageHistoryEntry[] { ): TokenUsageHistoryEntry[] {
const entries: TokenUsageHistoryEntry[] = []; const entries: TokenUsageHistoryEntry[] = [];
const lines = content.split(/\r?\n/).filter(Boolean); const lines = content.split(/\r?\n/).filter(Boolean);
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
: Number.POSITIVE_INFINITY;
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i -= 1) { for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) {
let parsed: TranscriptLineShape; let parsed: TranscriptLineShape;
try { try {
parsed = JSON.parse(lines[i]) as TranscriptLineShape; parsed = JSON.parse(lines[i]) as TranscriptLineShape;

View File

@@ -46,18 +46,21 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
} }
} }
export async function getRecentTokenUsageHistory(limit = 20): Promise<TokenUsageHistoryEntry[]> { export async function getRecentTokenUsageHistory(limit?: number): Promise<TokenUsageHistoryEntry[]> {
const files = await listRecentSessionFiles(); const files = await listRecentSessionFiles();
const results: TokenUsageHistoryEntry[] = []; const results: TokenUsageHistoryEntry[] = [];
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
: Number.POSITIVE_INFINITY;
for (const file of files) { for (const file of files) {
if (results.length >= limit) break; if (results.length >= maxEntries) break;
try { try {
const content = await readFile(file.filePath, 'utf8'); const content = await readFile(file.filePath, 'utf8');
const entries = parseUsageEntriesFromJsonl(content, { const entries = parseUsageEntriesFromJsonl(content, {
sessionId: file.sessionId, sessionId: file.sessionId,
agentId: file.agentId, agentId: file.agentId,
}, limit - results.length); }, Number.isFinite(maxEntries) ? maxEntries - results.length : undefined);
results.push(...entries); results.push(...entries);
} catch (error) { } catch (error) {
logger.debug(`Failed to read token usage transcript ${file.filePath}:`, error); logger.debug(`Failed to read token usage transcript ${file.filePath}:`, error);
@@ -65,5 +68,5 @@ export async function getRecentTokenUsageHistory(limit = 20): Promise<TokenUsage
} }
results.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); results.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
return results.slice(0, limit); return Number.isFinite(maxEntries) ? results.slice(0, maxEntries) : results;
} }

View File

@@ -30,11 +30,14 @@ import {
type ProviderType, type ProviderType,
getProviderIconUrl, getProviderIconUrl,
resolveProviderApiKeyForSave, resolveProviderApiKeyForSave,
resolveProviderModelForSave,
shouldShowProviderModelId,
shouldInvertInDark, shouldInvertInDark,
} from '@/lib/providers'; } from '@/lib/providers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '@/stores/settings';
function normalizeFallbackProviderIds(ids?: string[]): string[] { function normalizeFallbackProviderIds(ids?: string[]): string[] {
return Array.from(new Set((ids ?? []).filter(Boolean))); return Array.from(new Set((ids ?? []).filter(Boolean)));
@@ -58,6 +61,7 @@ function fallbackModelsEqual(a?: string[], b?: string[]): boolean {
export function ProvidersSettings() { export function ProvidersSettings() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const { const {
providers, providers,
defaultProviderId, defaultProviderId,
@@ -180,6 +184,7 @@ export function ProvidersSettings() {
setEditingProvider(null); setEditingProvider(null);
}} }}
onValidateKey={(key, options) => validateApiKey(provider.id, key, options)} onValidateKey={(key, options) => validateApiKey(provider.id, key, options)}
devModeUnlocked={devModeUnlocked}
/> />
))} ))}
</div> </div>
@@ -192,6 +197,7 @@ export function ProvidersSettings() {
onClose={() => setShowAddDialog(false)} onClose={() => setShowAddDialog(false)}
onAdd={handleAddProvider} onAdd={handleAddProvider}
onValidateKey={(type, key, options) => validateApiKey(type, key, options)} onValidateKey={(type, key, options) => validateApiKey(type, key, options)}
devModeUnlocked={devModeUnlocked}
/> />
)} )}
</div> </div>
@@ -212,6 +218,7 @@ interface ProviderCardProps {
key: string, key: string,
options?: { baseUrl?: string } options?: { baseUrl?: string }
) => Promise<{ valid: boolean; error?: string }>; ) => Promise<{ valid: boolean; error?: string }>;
devModeUnlocked: boolean;
} }
@@ -227,6 +234,7 @@ function ProviderCard({
onSetDefault, onSetDefault,
onSaveEdits, onSaveEdits,
onValidateKey, onValidateKey,
devModeUnlocked,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const [newKey, setNewKey] = useState(''); const [newKey, setNewKey] = useState('');
@@ -243,7 +251,8 @@ function ProviderCard({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type);
const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || typeInfo?.showModelId); const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField);
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
@@ -287,7 +296,7 @@ function ProviderCard({
} }
{ {
if (typeInfo?.showModelId && !modelId.trim()) { if (showModelIdField && !modelId.trim()) {
toast.error(t('aiProviders.toast.modelRequired')); toast.error(t('aiProviders.toast.modelRequired'));
setSaving(false); setSaving(false);
return; return;
@@ -297,7 +306,7 @@ function ProviderCard({
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) { if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) {
updates.baseUrl = baseUrl.trim() || undefined; updates.baseUrl = baseUrl.trim() || undefined;
} }
if (typeInfo?.showModelId && (modelId.trim() || undefined) !== (provider.model || undefined)) { if (showModelIdField && (modelId.trim() || undefined) !== (provider.model || undefined)) {
updates.model = modelId.trim() || undefined; updates.model = modelId.trim() || undefined;
} }
if (!fallbackModelsEqual(normalizedFallbackModels, provider.fallbackModels)) { if (!fallbackModelsEqual(normalizedFallbackModels, provider.fallbackModels)) {
@@ -371,13 +380,13 @@ function ProviderCard({
/> />
</div> </div>
)} )}
{typeInfo?.showModelId && ( {showModelIdField && (
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">{t('aiProviders.dialog.modelId')}</Label> <Label className="text-xs">{t('aiProviders.dialog.modelId')}</Label>
<Input <Input
value={modelId} value={modelId}
onChange={(e) => setModelId(e.target.value)} onChange={(e) => setModelId(e.target.value)}
placeholder={typeInfo.modelIdPlaceholder || 'provider/model-id'} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'}
className="h-9 text-sm" className="h-9 text-sm"
/> />
</div> </div>
@@ -479,7 +488,7 @@ function ProviderCard({
&& fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), provider.fallbackModels) && fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), provider.fallbackModels)
&& fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds) && fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)
) )
|| Boolean(typeInfo?.showModelId && !modelId.trim()) || Boolean(showModelIdField && !modelId.trim())
} }
> >
{validating || saving ? ( {validating || saving ? (
@@ -581,9 +590,16 @@ interface AddProviderDialogProps {
apiKey: string, apiKey: string,
options?: { baseUrl?: string } options?: { baseUrl?: string }
) => Promise<{ valid: boolean; error?: string }>; ) => Promise<{ valid: boolean; error?: string }>;
devModeUnlocked: boolean;
} }
function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: AddProviderDialogProps) { function AddProviderDialog({
existingTypes,
onClose,
onAdd,
onValidateKey,
devModeUnlocked,
}: AddProviderDialogProps) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const [selectedType, setSelectedType] = useState<ProviderType | null>(null); const [selectedType, setSelectedType] = useState<ProviderType | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -606,6 +622,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth'); const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth');
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType);
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
const isOAuth = typeInfo?.isOAuth ?? false; const isOAuth = typeInfo?.isOAuth ?? false;
const supportsApiKey = typeInfo?.supportsApiKey ?? false; const supportsApiKey = typeInfo?.supportsApiKey ?? false;
// Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected // Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected
@@ -740,7 +757,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
} }
} }
const requiresModel = typeInfo?.showModelId ?? false; const requiresModel = showModelIdField;
if (requiresModel && !modelId.trim()) { if (requiresModel && !modelId.trim()) {
setValidationError(t('aiProviders.toast.modelRequired')); setValidationError(t('aiProviders.toast.modelRequired'));
setSaving(false); setSaving(false);
@@ -753,7 +770,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
apiKey.trim(), apiKey.trim(),
{ {
baseUrl: baseUrl.trim() || undefined, baseUrl: baseUrl.trim() || undefined,
model: (typeInfo?.defaultModelId || modelId.trim()) || undefined, model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked),
} }
); );
} catch { } catch {
@@ -911,12 +928,12 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
</div> </div>
)} )}
{typeInfo?.showModelId && ( {showModelIdField && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="modelId">{t('aiProviders.dialog.modelId')}</Label> <Label htmlFor="modelId">{t('aiProviders.dialog.modelId')}</Label>
<Input <Input
id="modelId" id="modelId"
placeholder={typeInfo.modelIdPlaceholder || 'provider/model-id'} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'}
value={modelId} value={modelId}
onChange={(e) => { onChange={(e) => {
setModelId(e.target.value); setModelId(e.target.value);
@@ -1029,7 +1046,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
<Button <Button
onClick={handleAdd} onClick={handleAdd}
className={cn(useOAuthFlow && "hidden")} className={cn(useOAuthFlow && "hidden")}
disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)} disabled={!selectedType || saving || (showModelIdField && modelId.trim().length === 0)}
> >
{saving ? ( {saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />

View File

@@ -35,7 +35,7 @@
"last7Days": "7 days", "last7Days": "7 days",
"last30Days": "30 days", "last30Days": "30 days",
"allTime": "All", "allTime": "All",
"showingLast": "Showing the latest {{count}} records", "showingLast": "{{count}} records",
"totalTokens": "total tokens", "totalTokens": "total tokens",
"inputShort": "Input", "inputShort": "Input",
"outputShort": "Output", "outputShort": "Output",

View File

@@ -35,7 +35,7 @@
"last7Days": "7日", "last7Days": "7日",
"last30Days": "30日", "last30Days": "30日",
"allTime": "すべて", "allTime": "すべて",
"showingLast": "最新 {{count}} 件を表示", "showingLast": "{{count}} 件",
"totalTokens": "合計トークン", "totalTokens": "合計トークン",
"inputShort": "入力", "inputShort": "入力",
"outputShort": "出力", "outputShort": "出力",

View File

@@ -35,7 +35,7 @@
"last7Days": "7 天", "last7Days": "7 天",
"last30Days": "30 天", "last30Days": "30 天",
"allTime": "全部", "allTime": "全部",
"showingLast": "显示最近 {{count}} 条记录", "showingLast": " {{count}} 条记录",
"totalTokens": "总 token", "totalTokens": "总 token",
"inputShort": "输入", "inputShort": "输入",
"outputShort": "输出", "outputShort": "输出",

View File

@@ -55,6 +55,8 @@ export interface ProviderTypeInfo {
showBaseUrl?: boolean; showBaseUrl?: boolean;
/** Whether to show a Model ID input field (for providers where user picks the model) */ /** Whether to show a Model ID input field (for providers where user picks the model) */
showModelId?: boolean; showModelId?: boolean;
/** Whether the Model ID input should only be shown in developer mode */
showModelIdInDevModeOnly?: boolean;
/** Default / example model ID placeholder */ /** Default / example model ID placeholder */
modelIdPlaceholder?: string; modelIdPlaceholder?: string;
/** Default model ID to pre-fill */ /** Default model ID to pre-fill */
@@ -74,10 +76,10 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true }, { id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true },
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true }, { id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true }, { id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true },
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true }, { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' }, { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', defaultModelId: 'Pro/moonshotai/Kimi-K2.5' }, { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3' },
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://intl.minimaxi.com/' }, { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://intl.minimaxi.com/' },
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://platform.minimaxi.com/' }, { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://platform.minimaxi.com/' },
{ id: 'qwen-portal', name: 'Qwen', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' }, { id: 'qwen-portal', name: 'Qwen', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' },
@@ -103,6 +105,28 @@ export function getProviderTypeInfo(type: ProviderType): ProviderTypeInfo | unde
return PROVIDER_TYPE_INFO.find((t) => t.id === type); return PROVIDER_TYPE_INFO.find((t) => t.id === type);
} }
export function shouldShowProviderModelId(
provider: Pick<ProviderTypeInfo, 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
devModeUnlocked: boolean
): boolean {
if (!provider?.showModelId) return false;
if (provider.showModelIdInDevModeOnly && !devModeUnlocked) return false;
return true;
}
export function resolveProviderModelForSave(
provider: Pick<ProviderTypeInfo, 'defaultModelId' | 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
modelId: string,
devModeUnlocked: boolean
): string | undefined {
if (!shouldShowProviderModelId(provider, devModeUnlocked)) {
return undefined;
}
const trimmedModelId = modelId.trim();
return trimmedModelId || provider?.defaultModelId || undefined;
}
/** Normalize provider API key before saving; Ollama uses a local placeholder when blank. */ /** Normalize provider API key before saving; Ollama uses a local placeholder when blank. */
export function resolveProviderApiKeyForSave(type: ProviderType | string, apiKey: string): string | undefined { export function resolveProviderApiKeyForSave(type: ProviderType | string, apiKey: string): string | undefined {
const trimmed = apiKey.trim(); const trimmed = apiKey.trim();

View File

@@ -63,7 +63,7 @@ export function Dashboard() {
if (isGatewayRunning) { if (isGatewayRunning) {
fetchChannels(); fetchChannels();
fetchSkills(); fetchSkills();
window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 60) window.electron.ipcRenderer.invoke('usage:recentTokenHistory')
.then((entries) => { .then((entries) => {
setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []); setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []);
setUsagePage(1); setUsagePage(1);

View File

@@ -103,7 +103,15 @@ const defaultSkills: DefaultSkill[] = [
{ id: 'terminal', name: 'Terminal', description: 'Shell command execution' }, { id: 'terminal', name: 'Terminal', description: 'Shell command execution' },
]; ];
import { SETUP_PROVIDERS, type ProviderTypeInfo, getProviderIconUrl, resolveProviderApiKeyForSave, shouldInvertInDark } from '@/lib/providers'; import {
SETUP_PROVIDERS,
type ProviderTypeInfo,
getProviderIconUrl,
resolveProviderApiKeyForSave,
resolveProviderModelForSave,
shouldInvertInDark,
shouldShowProviderModelId,
} from '@/lib/providers';
import clawxIcon from '@/assets/logo.svg'; import clawxIcon from '@/assets/logo.svg';
// Use the shared provider registry for setup providers // Use the shared provider registry for setup providers
@@ -707,6 +715,7 @@ function ProviderContent({
onConfiguredChange, onConfiguredChange,
}: ProviderContentProps) { }: ProviderContentProps) {
const { t } = useTranslation(['setup', 'settings']); const { t } = useTranslation(['setup', 'settings']);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [keyValid, setKeyValid] = useState<boolean | null>(null); const [keyValid, setKeyValid] = useState<boolean | null>(null);
@@ -910,7 +919,7 @@ function ProviderContent({
? getProviderIconUrl(selectedProviderData.id) ? getProviderIconUrl(selectedProviderData.id)
: undefined; : undefined;
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false; const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
const showModelIdField = selectedProviderData?.showModelId ?? false; const showModelIdField = shouldShowProviderModelId(selectedProviderData, devModeUnlocked);
const requiresKey = selectedProviderData?.requiresApiKey ?? false; const requiresKey = selectedProviderData?.requiresApiKey ?? false;
const isOAuth = selectedProviderData?.isOAuth ?? false; const isOAuth = selectedProviderData?.isOAuth ?? false;
const supportsApiKey = selectedProviderData?.supportsApiKey ?? false; const supportsApiKey = selectedProviderData?.supportsApiKey ?? false;
@@ -958,10 +967,11 @@ function ProviderContent({
setKeyValid(true); setKeyValid(true);
} }
const effectiveModelId = const effectiveModelId = resolveProviderModelForSave(
selectedProviderData?.defaultModelId || selectedProviderData,
modelId.trim() || modelId,
undefined; devModeUnlocked
);
const providerIdForSave = const providerIdForSave =
selectedProvider === 'custom' selectedProvider === 'custom'

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { PROVIDER_TYPES, PROVIDER_TYPE_INFO, resolveProviderApiKeyForSave } from '@/lib/providers'; import {
PROVIDER_TYPES,
PROVIDER_TYPE_INFO,
resolveProviderApiKeyForSave,
resolveProviderModelForSave,
shouldShowProviderModelId,
} from '@/lib/providers';
import { import {
BUILTIN_PROVIDER_TYPES, BUILTIN_PROVIDER_TYPES,
getProviderConfig, getProviderConfig,
@@ -54,6 +60,43 @@ describe('provider metadata', () => {
); );
}); });
it('only exposes OpenRouter and SiliconFlow model overrides in developer mode', () => {
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
expect(openrouter).toMatchObject({
showModelId: true,
showModelIdInDevModeOnly: true,
defaultModelId: 'anthropic/claude-opus-4.6',
});
expect(siliconflow).toMatchObject({
showModelId: true,
showModelIdInDevModeOnly: true,
defaultModelId: 'deepseek-ai/DeepSeek-V3',
});
expect(shouldShowProviderModelId(openrouter, false)).toBe(false);
expect(shouldShowProviderModelId(siliconflow, false)).toBe(false);
expect(shouldShowProviderModelId(openrouter, true)).toBe(true);
expect(shouldShowProviderModelId(siliconflow, true)).toBe(true);
});
it('only saves OpenRouter and SiliconFlow model overrides in developer mode', () => {
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
const ark = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'ark');
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', false)).toBeUndefined();
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', false)).toBeUndefined();
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', true)).toBe('openai/gpt-5');
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', true)).toBe('Qwen/Qwen3-Coder-480B-A35B-Instruct');
expect(resolveProviderModelForSave(openrouter, ' ', true)).toBe('anthropic/claude-opus-4.6');
expect(resolveProviderModelForSave(siliconflow, ' ', true)).toBe('deepseek-ai/DeepSeek-V3');
expect(resolveProviderModelForSave(ark, ' ep-custom-model ', false)).toBe('ep-custom-model');
});
it('normalizes provider API keys for save flow', () => { it('normalizes provider API keys for save flow', () => {
expect(resolveProviderApiKeyForSave('ollama', '')).toBe('ollama-local'); expect(resolveProviderApiKeyForSave('ollama', '')).toBe('ollama-local');
expect(resolveProviderApiKeyForSave('ollama', ' ')).toBe('ollama-local'); expect(resolveProviderApiKeyForSave('ollama', ' ')).toBe('ollama-local');

View File

@@ -80,4 +80,52 @@ describe('parseUsageEntriesFromJsonl', () => {
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]); expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
}); });
it('returns all matching entries when no limit is provided', () => {
const jsonl = [
JSON.stringify({
type: 'message',
timestamp: '2026-02-28T10:00:00.000Z',
message: { role: 'assistant', model: 'm1', usage: { total: 10 } },
}),
JSON.stringify({
type: 'message',
timestamp: '2026-02-28T10:01:00.000Z',
message: { role: 'assistant', model: 'm2', usage: { total: 20 } },
}),
JSON.stringify({
type: 'message',
timestamp: '2026-02-28T10:02:00.000Z',
message: { role: 'assistant', model: 'm3', usage: { total: 30 } },
}),
].join('\n');
const entries = parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' });
expect(entries).toHaveLength(3);
expect(entries.map((entry) => entry.model)).toEqual(['m3', 'm2', 'm1']);
});
it('still supports explicit limits when provided', () => {
const jsonl = [
JSON.stringify({
type: 'message',
timestamp: '2026-02-28T10:00:00.000Z',
message: { role: 'assistant', model: 'm1', usage: { total: 10 } },
}),
JSON.stringify({
type: 'message',
timestamp: '2026-02-28T10:01:00.000Z',
message: { role: 'assistant', model: 'm2', usage: { total: 20 } },
}),
JSON.stringify({
type: 'message',
timestamp: '2026-02-28T10:02:00.000Z',
message: { role: 'assistant', model: 'm3', usage: { total: 30 } },
}),
].join('\n');
const entries = parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' }, 2);
expect(entries).toHaveLength(2);
expect(entries.map((entry) => entry.model)).toEqual(['m3', 'm2']);
});
}); });