Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions src/vs/workbench/contrib/cortexide/browser/cortexideStatusBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@ import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/
import { IChatThreadService } from './chatThreadService.js';
import { localProviderNames } from '../common/cortexideSettingsTypes.js';
import { ProviderName } from '../common/cortexideSettingsTypes.js';
import { IFreeTierQuotaService, FreeTierRemaining } from '../common/routing/freeTierQuotaService.js';
import { freeTierIdOfProviderName, FREE_TIER_QUOTAS } from '../common/routing/freeTierConstants.js';
import { ICortexideI18nService } from '../common/i18n/i18nService.js';

export class CortexideStatusBarContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.cortexideStatusBar';

private modelEntry: IStatusbarEntryAccessor | undefined;
private latencyEntry: IStatusbarEntryAccessor | undefined;
private privacyEntry: IStatusbarEntryAccessor | undefined;
private freeTierEntry: IStatusbarEntryAccessor | undefined;
private readonly updateDisposables = this._register(new MutableDisposable());

constructor(
@IStatusbarService private readonly statusbarService: IStatusbarService,
@ICortexideSettingsService private readonly cortexideSettingsService: ICortexideSettingsService,
@IChatThreadService private readonly chatThreadService: IChatThreadService,
@IFreeTierQuotaService private readonly freeTierQuotaService: IFreeTierQuotaService,
@ICortexideI18nService private readonly i18nService: ICortexideI18nService,
) {
super();
this.create();
Expand Down Expand Up @@ -56,6 +62,14 @@ export class CortexideStatusBarContribution extends Disposable implements IWorkb
StatusbarAlignment.RIGHT,
{ location: { id: 'status.editor.mode', priority: 100.4 }, alignment: StatusbarAlignment.RIGHT }
);

// Free-tier quota widget
this.freeTierEntry = this.statusbarService.addEntry(
this.getFreeTierEntryProps(),
'cortexide.freeTier',
StatusbarAlignment.RIGHT,
{ location: { id: 'status.editor.mode', priority: 100.5 }, alignment: StatusbarAlignment.RIGHT }
);
}

private registerListeners(): void {
Expand All @@ -76,6 +90,20 @@ export class CortexideStatusBarContribution extends Disposable implements IWorkb
}, 500);

this._register({ dispose: () => clearInterval(latencyUpdateInterval) });

// Refresh free-tier widget on every quota mutation (recordCall, markExhausted)
this._register(this.freeTierQuotaService.onQuotaChange(() => {
this.freeTierEntry?.update(this.getFreeTierEntryProps());
}));
// Also refresh on settings changes so newly-added providers appear immediately
this._register(this.cortexideSettingsService.onDidChangeState(() => {
this.freeTierEntry?.update(this.getFreeTierEntryProps());
}));
// Slow tick to keep window rollovers visible to the user
const quotaTick = setInterval(() => {
this.freeTierEntry?.update(this.getFreeTierEntryProps());
}, 15_000);
this._register({ dispose: () => clearInterval(quotaTick) });
}

private getModelEntryProps(): IStatusbarEntry {
Expand Down Expand Up @@ -229,14 +257,109 @@ export class CortexideStatusBarContribution extends Disposable implements IWorkb
};
}

/**
* Free-tier quota widget. Hides itself when no free-tier providers are
* configured; otherwise shows the most-constrained remaining metric for
* the top-quality provider, with a multiline tooltip listing every
* provider's status.
*/
private getFreeTierEntryProps(): IStatusbarEntry {
const t = (key: Parameters<typeof this.i18nService.t>[0], fallback?: string) => this.i18nService.t(key, fallback);
const configuredFreeTierProviders = this.collectConfiguredFreeTierProviders();
if (configuredFreeTierProviders.length === 0) {
return {
name: t('routing.statusBar.label', 'Free-tier quota'),
text: '',
ariaLabel: '',
tooltip: t('routing.statusBar.tooltipNoProviders', 'No free-tier providers configured.'),
};
}

// Build display text from the highest-quality configured provider.
// Sort by quality rank descending and use the first usable entry.
const enriched = configuredFreeTierProviders
.map(p => ({ ...p, remaining: this.freeTierQuotaService.getRemaining(p.providerId, p.modelName) }))
.sort((a, b) => b.qualityRank - a.qualityRank);

const top = enriched[0];
let text: string;
if (top.remaining.exhausted) {
text = `$(warning) ${this.formatProviderStatus(top.remaining)}`;
} else if (top.remaining.rpd !== null && top.remaining.limits.rpd !== null) {
text = `$(pulse) ${this.formatProviderStatus(top.remaining)}`;
} else if (top.remaining.rpm !== null && top.remaining.limits.rpm !== null) {
text = `$(pulse) ${this.formatProviderStatus(top.remaining)}`;
} else {
text = `$(pulse) ${this.formatProviderStatus(top.remaining)}`;
}

// Multiline tooltip listing every provider's status.
const lines: string[] = [t('routing.statusBar.tooltipTitle', 'Free-tier provider quotas')];
for (const p of enriched) {
lines.push(this.formatProviderStatus(p.remaining));
}
const tooltip = lines.join('\n');

return {
name: t('routing.statusBar.label', 'Free-tier quota'),
text,
ariaLabel: text,
tooltip,
};
}

/**
* Inspect settings to find configured free-tier providers with at least
* one visible model. Returns provider id + first visible model name.
*/
private collectConfiguredFreeTierProviders(): Array<{ providerId: NonNullable<ReturnType<typeof freeTierIdOfProviderName>>; providerName: ProviderName; modelName: string; qualityRank: number }> {
const settings = this.cortexideSettingsService.state;
const out: Array<{ providerId: NonNullable<ReturnType<typeof freeTierIdOfProviderName>>; providerName: ProviderName; modelName: string; qualityRank: number }> = [];
for (const providerName of Object.keys(settings.settingsOfProvider) as ProviderName[]) {
const ps = settings.settingsOfProvider[providerName];
if (!ps._didFillInProviderSettings) continue;
const ftId = freeTierIdOfProviderName(providerName);
if (ftId === null) continue;
const firstModel = ps.models.find(m => !m.isHidden);
if (!firstModel) continue;
out.push({
providerId: ftId,
providerName,
modelName: firstModel.modelName,
qualityRank: FREE_TIER_QUOTAS[ftId].qualityRank,
});
}
return out;
}

private formatProviderStatus(remaining: FreeTierRemaining): string {
const t = (key: Parameters<typeof this.i18nService.t>[0], ...args: string[]) =>
args.reduce((acc, arg, i) => acc.replace(`{${i}}`, arg), this.i18nService.t(key));
const name = remaining.providerId;
if (remaining.exhausted) {
return t('routing.statusBar.exhausted', name);
}
if (remaining.rpd !== null && remaining.limits.rpd !== null) {
const used = remaining.limits.rpd - remaining.rpd;
return t('routing.statusBar.entry', name, String(used), String(remaining.limits.rpd));
}
if (remaining.rpm !== null && remaining.limits.rpm !== null) {
const used = remaining.limits.rpm - remaining.rpm;
return t('routing.statusBar.entryRpm', name, String(used), String(remaining.limits.rpm));
}
return t('routing.statusBar.uncapped', name);
}

override dispose(): void {
super.dispose();
this.modelEntry?.dispose();
this.latencyEntry?.dispose();
this.privacyEntry?.dispose();
this.freeTierEntry?.dispose();
this.modelEntry = undefined;
this.latencyEntry = undefined;
this.privacyEntry = undefined;
this.freeTierEntry = undefined;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1932,6 +1932,29 @@ export const Settings = () => {
</div>
</div>

{/* Routing Policy Section */}
<ErrorBoundary>
<div>
<h4 className={`text-base`}>Routing policy</h4>
<div className='text-sm text-void-fg-3 mt-1'>
Controls how CortexIDE picks between configured model providers. Free-tier ladder tracks per-provider quotas and auto-fails-over on 429.
</div>
<div className='my-2'>
<select
className='text-xs bg-void-bg-1 text-void-fg-1 border border-void-border-1 rounded px-1 py-0.5'
value={settingsState.globalSettings.routingPolicy ?? 'auto-cheapest'}
onChange={(e) => cortexideSettingsService.setGlobalSetting('routingPolicy', e.target.value as ('auto-cheapest' | 'free-tier' | 'local-only' | 'byok-paid'))}
title='Routing policy'
>
<option value='auto-cheapest'>Auto (cheapest viable)</option>
<option value='free-tier'>Free-tier ladder</option>
<option value='local-only'>Local only</option>
<option value='byok-paid'>BYOK paid models</option>
</select>
</div>
</div>
</ErrorBoundary>

{/* YOLO Mode Section */}
<ErrorBoundary>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,18 @@ export type GlobalSettings = {
};
// Local-First AI: When enabled, heavily bias router toward local models
localFirstAI?: boolean; // Prefer local models over cloud models (default: false)
// Routing policy: controls how the model router selects between configured providers.
// - 'auto-cheapest': existing behaviour - score-based mixture of rules + learned (default)
// - 'free-tier': prefer free-tier providers in quality-ranked order with quota tracking
// - 'local-only': never dispatch to a cloud provider, even if the model selection points there
// - 'byok-paid': prefer paid BYOK models, skipping free-tier ladders entirely
routingPolicy?: RoutingPolicy;
}

/** User-selectable routing policy for the model router. */
export type RoutingPolicy = 'auto-cheapest' | 'free-tier' | 'local-only' | 'byok-paid';
export const routingPolicies: readonly RoutingPolicy[] = ['auto-cheapest', 'free-tier', 'local-only', 'byok-paid'];

export const defaultGlobalSettings: GlobalSettings = {
autoRefreshModels: true,
aiInstructions: '',
Expand Down Expand Up @@ -589,6 +599,7 @@ export const defaultGlobalSettings: GlobalSettings = {
routerCacheTtlMs: 2000, // 2 second cache TTL (caching enabled)
},
localFirstAI: false, // Local-First AI disabled by default (users can enable for privacy/performance)
routingPolicy: 'auto-cheapest', // Existing scoring behaviour remains the default
}

export type GlobalSettingName = keyof GlobalSettings
Expand Down
17 changes: 17 additions & 0 deletions src/vs/workbench/contrib/cortexide/common/i18n/i18nService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,23 @@ export const EN_TRANSLATIONS = {
'common.copy': 'Copy',
'common.copied': 'Copied!',
'common.open': 'Open',

// allow-any-unicode-next-line
// ── Routing / free-tier router ───────────────────────────────────────────
'routing.policy.label': 'Routing policy',
'routing.policy.description': 'Controls how CortexIDE picks between configured model providers.',
'routing.policy.autoCheapest': 'Auto (cheapest viable)',
'routing.policy.freeTier': 'Free-tier ladder',
'routing.policy.localOnly': 'Local only',
'routing.policy.byokPaid': 'BYOK paid models',
'routing.statusBar.label': 'Free-tier quota',
'routing.statusBar.none': 'No free-tier providers',
'routing.statusBar.entry': '{0}: {1}/{2} RPD',
'routing.statusBar.entryRpm': '{0}: {1}/{2} RPM',
'routing.statusBar.exhausted': '{0}: exhausted',
'routing.statusBar.uncapped': '{0}: uncapped',
'routing.statusBar.tooltipTitle': 'Free-tier provider quotas',
'routing.statusBar.tooltipNoProviders': 'No free-tier providers are configured. Add a free-tier API key (Groq, Gemini, OpenRouter, Mistral) to see live quota tracking.',
} as const;

// allow-any-unicode-next-line
Expand Down
82 changes: 80 additions & 2 deletions src/vs/workbench/contrib/cortexide/common/modelRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { ProviderName, ModelSelection } from './cortexideSettingsTypes.js';
import { getModelCapabilities, CortexideStaticModelInfo } from './modelCapabilities.js';
import { ICortexideSettingsService } from './cortexideSettingsService.js';
import { ICortexideSettingsService, CortexideSettingsState } from './cortexideSettingsService.js';
import { localProviderNames } from './cortexideSettingsTypes.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
Expand All @@ -14,6 +14,8 @@ import { RoutingEvaluationService } from './routingEvaluation.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { shouldUseSpeculativeEscalation } from './routingEscalation.js';
import { getPerformanceHarness } from './performanceHarness.js';
import { IFreeTierQuotaService } from './routing/freeTierQuotaService.js';
import { buildFreeTierLadder, pickTopFromLadder } from './routing/freeTierLadder.js';

/**
* Task types for automatic model selection
Expand Down Expand Up @@ -90,7 +92,8 @@ export class TaskAwareModelRouter extends Disposable implements ITaskAwareModelR

constructor(
@ICortexideSettingsService private readonly settingsService: ICortexideSettingsService,
@IStorageService private readonly storageService: IStorageService
@IStorageService private readonly storageService: IStorageService,
@IFreeTierQuotaService private readonly freeTierQuotaService: IFreeTierQuotaService,
) {
super();
this.evaluationService = new RoutingEvaluationService(this.storageService);
Expand Down Expand Up @@ -198,6 +201,34 @@ export class TaskAwareModelRouter extends Disposable implements ITaskAwareModelR
// This is handled in scoreModel by applying heavy bonuses to local models
}

// Routing policy: 'free-tier' -> consult the smart free-tier router first.
// If the ladder is empty (no configured free-tier providers, all exhausted,
// or privacy gate engaged), fall through to the standard scoring path so
// the user is never stranded.
const routingPolicy = settingsState.globalSettings.routingPolicy ?? 'auto-cheapest';
if (routingPolicy === 'free-tier') {
const ladderDecision = this.routeViaFreeTierLadder(context, settingsState);
if (ladderDecision) {
this.routingCache.set(cacheKey, { decision: ladderDecision, timestamp: Date.now() });
return ladderDecision;
}
} else if (routingPolicy === 'local-only') {
// Hard local-only: refuse to dispatch to any cloud provider.
const localDecision = this.routeToLocalModel(context);
if (localDecision) {
this.routingCache.set(cacheKey, { decision: localDecision, timestamp: Date.now() });
return localDecision;
}
return {
modelSelection: { providerName: 'auto', modelName: 'auto' },
confidence: 0.0,
reasoning: 'Routing policy is local-only but no local models are configured.',
qualityTier: 'abstain',
shouldAbstain: true,
abstainReason: 'No local models for local-only routing policy',
};
}

// Quality gate: pre-flight quality estimate
const qualityTier = this.estimateQualityTier(context);

Expand Down Expand Up @@ -1438,6 +1469,53 @@ export class TaskAwareModelRouter extends Disposable implements ITaskAwareModelR
* Route to a local model (privacy/offline mode)
* Returns null if no local models are available (caller must handle fallback)
*/
/**
* Route via the smart free-tier ladder. Returns `null` when no free-tier
* provider is currently usable (caller should fall through to standard
* scoring or local fallback).
*
* Cloud providers are only considered when the privacy gate is NOT engaged
* - `requiresPrivacy` short-circuits to `null` here so callers can route
* to local.
*/
private routeViaFreeTierLadder(
context: TaskContext,
settingsState: CortexideSettingsState,
): RoutingDecision | null {
if (context.requiresPrivacy) {
return null;
}

const configured = this.getAvailableModels(settingsState);
const quotas = this.freeTierQuotaService.getAllRemaining();
const ladder = buildFreeTierLadder({
configuredModels: configured,
quotas,
privacyMode: !!context.requiresPrivacy,
});

const top = pickTopFromLadder(ladder);
if (!top) {
return null;
}

const fallbackChain: ModelSelection[] = ladder.slice(1, 4).map(c => ({
providerName: c.providerName,
modelName: c.modelName,
}));

const timeoutMs = this.getModelTimeout(top, context, settingsState);

return {
modelSelection: top,
confidence: 0.75,
reasoning: `Free-tier ladder selected ${top.providerName}/${top.modelName} (next: ${fallbackChain.map(m => m.providerName).join(', ') || 'none'})`,
fallbackChain,
qualityTier: 'cheap_fast',
timeoutMs,
};
}

private routeToLocalModel(context: TaskContext): RoutingDecision | null {
const settingsState = this.settingsService.state;
const localModels: ModelSelection[] = [];
Expand Down
Loading
Loading