Move AI cost budget setting into AI settings
This commit is contained in:
parent
1294a66f56
commit
9c78cf7f84
2 changed files with 62 additions and 53 deletions
|
|
@ -57,8 +57,6 @@ export const AICostDashboard: Component = () => {
|
|||
const [loadError, setLoadError] = createSignal<string | null>(null);
|
||||
const [summary, setSummary] = createSignal<AICostSummary | null>(null);
|
||||
const [aiSettings, setAISettings] = createSignal<AISettings | null>(null);
|
||||
const [budgetUSD30dInput, setBudgetUSD30dInput] = createSignal<string>('');
|
||||
const [savingBudget, setSavingBudget] = createSignal(false);
|
||||
let requestSeq = 0;
|
||||
|
||||
const anyPricingKnown = createMemo(() => {
|
||||
|
|
@ -124,15 +122,9 @@ export const AICostDashboard: Component = () => {
|
|||
try {
|
||||
const s = await AIAPI.getSettings();
|
||||
setAISettings(s);
|
||||
if (typeof s.cost_budget_usd_30d === 'number' && Number.isFinite(s.cost_budget_usd_30d) && s.cost_budget_usd_30d > 0) {
|
||||
setBudgetUSD30dInput(String(s.cost_budget_usd_30d));
|
||||
} else {
|
||||
setBudgetUSD30dInput('');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('[AICostDashboard] Failed to load AI settings for budget:', err);
|
||||
setAISettings(null);
|
||||
setBudgetUSD30dInput('');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -141,10 +133,9 @@ export const AICostDashboard: Component = () => {
|
|||
});
|
||||
|
||||
const parsedBudgetUSD30d = createMemo(() => {
|
||||
const raw = budgetUSD30dInput().trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const s = aiSettings();
|
||||
const n = s?.cost_budget_usd_30d;
|
||||
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null;
|
||||
return n;
|
||||
});
|
||||
|
||||
|
|
@ -162,30 +153,6 @@ export const AICostDashboard: Component = () => {
|
|||
return usd > budget;
|
||||
});
|
||||
|
||||
const isDirtyBudget = createMemo(() => {
|
||||
const s = aiSettings();
|
||||
const current = typeof s?.cost_budget_usd_30d === 'number' ? s.cost_budget_usd_30d : 0;
|
||||
const next = parsedBudgetUSD30d() ?? 0;
|
||||
return Math.abs(current - next) > 0.0001;
|
||||
});
|
||||
|
||||
const saveBudget = async () => {
|
||||
if (savingBudget()) return;
|
||||
setSavingBudget(true);
|
||||
try {
|
||||
const value = parsedBudgetUSD30d() ?? 0;
|
||||
const next = await AIAPI.updateSettings({ cost_budget_usd_30d: value });
|
||||
setAISettings(next);
|
||||
notificationStore.success('AI cost budget saved');
|
||||
} catch (err) {
|
||||
logger.error('[AICostDashboard] Failed to save budget:', err);
|
||||
notificationStore.error('Failed to save AI cost budget');
|
||||
await loadBudgetSettings();
|
||||
} finally {
|
||||
setSavingBudget(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetHistory = async () => {
|
||||
if (!confirm('Reset AI usage history? A backup will be created in the Pulse config directory.')) return;
|
||||
try {
|
||||
|
|
@ -386,25 +353,14 @@ export const AICostDashboard: Component = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Budget alert (USD per 30d)</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isDirtyBudget() || savingBudget()}
|
||||
onClick={saveBudget}
|
||||
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${(!isDirtyBudget() || savingBudget()) ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Budget alert (USD per 30d)</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white mt-1">
|
||||
<Show when={parsedBudgetUSD30d() != null} fallback={<span class="text-gray-500 dark:text-gray-400">—</span>}>
|
||||
{formatUSD(parsedBudgetUSD30d() ?? 0)}
|
||||
</Show>
|
||||
</div>
|
||||
<input
|
||||
value={budgetUSD30dInput()}
|
||||
onInput={(e) => setBudgetUSD30dInput(e.currentTarget.value)}
|
||||
placeholder="e.g. 25"
|
||||
class="mt-1 w-full px-2 py-1 text-sm rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-1">
|
||||
Cross-provider estimate. Pro-rated for {days()}d:{' '}
|
||||
Set in AI settings. Pro-rated for {days()}d:{' '}
|
||||
<Show when={budgetForRange() != null} fallback={<span>—</span>}>
|
||||
{formatUSD(budgetForRange() ?? 0)}
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ export const AISettings: Component = () => {
|
|||
deepseekApiKey: '',
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
openaiBaseUrl: '',
|
||||
// Cost controls
|
||||
costBudgetUSD30d: '',
|
||||
});
|
||||
|
||||
const resetForm = (data: AISettingsType | null) => {
|
||||
|
|
@ -116,6 +118,7 @@ export const AISettings: Component = () => {
|
|||
deepseekApiKey: '',
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
openaiBaseUrl: '',
|
||||
costBudgetUSD30d: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -140,6 +143,10 @@ export const AISettings: Component = () => {
|
|||
deepseekApiKey: '',
|
||||
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
|
||||
openaiBaseUrl: data.openai_base_url || '',
|
||||
costBudgetUSD30d:
|
||||
typeof data.cost_budget_usd_30d === 'number' && data.cost_budget_usd_30d > 0
|
||||
? String(data.cost_budget_usd_30d)
|
||||
: '',
|
||||
});
|
||||
|
||||
// Auto-expand providers that are configured
|
||||
|
|
@ -291,6 +298,20 @@ export const AISettings: Component = () => {
|
|||
payload.openai_base_url = form.openaiBaseUrl.trim();
|
||||
}
|
||||
|
||||
// Cost controls (server-side budget, cross-provider estimate)
|
||||
{
|
||||
const raw = form.costBudgetUSD30d.trim();
|
||||
const parsed = raw === '' ? 0 : Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
notificationStore.error('Cost budget must be a non-negative number');
|
||||
return;
|
||||
}
|
||||
const current = settings()?.cost_budget_usd_30d ?? 0;
|
||||
if (Math.abs(parsed - current) > 0.0001) {
|
||||
payload.cost_budget_usd_30d = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await AIAPI.updateSettings(payload);
|
||||
setSettings(updated);
|
||||
resetForm(updated);
|
||||
|
|
@ -1054,6 +1075,38 @@ export const AISettings: Component = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Cost Controls */}
|
||||
<div class={`${formField} p-4 rounded-lg border bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800`}>
|
||||
<div class="mb-2">
|
||||
<label class={`${labelClass()} flex items-center gap-2`}>
|
||||
AI Cost Controls
|
||||
</label>
|
||||
<p class="text-xs text-emerald-700 dark:text-emerald-300 mt-1">
|
||||
This budget is a cross-provider estimate for Pulse usage. Provider dashboards remain the source of truth for billing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Budget alert (USD per 30 days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
class={controlClass()}
|
||||
value={form.costBudgetUSD30d}
|
||||
onInput={(e) => setForm('costBudgetUSD30d', e.currentTarget.value)}
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="0 (disabled)"
|
||||
disabled={saving()}
|
||||
style={{ width: '180px' }}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Set to 0 to disable. Cost dashboard pro-rates for shorter ranges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue