Use Canonical Models to set context window sizes (#6723)

This commit is contained in:
David Katz
2026-02-17 11:43:10 -05:00
committed by GitHub
parent 576590d4c8
commit 3959805198
54 changed files with 465 additions and 581 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+38 -36
View File
@@ -620,7 +620,6 @@ export type MessageMetadata = {
export type ModelConfig = {
context_limit?: number | null;
fast_model?: string | null;
max_tokens?: number | null;
model_name: string;
/**
@@ -664,6 +663,28 @@ export type ModelInfo = {
supports_cache_control?: boolean | null;
};
export type ModelInfoData = {
cache_read_token_cost?: number | null;
cache_write_token_cost?: number | null;
context_limit: number;
currency: string;
input_token_cost?: number | null;
max_output_tokens?: number | null;
model: string;
output_token_cost?: number | null;
provider: string;
};
export type ModelInfoQuery = {
model: string;
provider: string;
};
export type ModelInfoResponse = {
model_info?: ModelInfoData | null;
source: string;
};
export type ParseRecipeRequest = {
content: string;
};
@@ -703,25 +724,6 @@ export type PermissionsMetadata = {
microphone?: boolean;
};
export type PricingData = {
context_length?: number | null;
currency: string;
input_token_cost: number;
model: string;
output_token_cost: number;
provider: string;
};
export type PricingQuery = {
model: string;
provider: string;
};
export type PricingResponse = {
pricing: Array<PricingData>;
source: string;
};
export type PrincipalType = 'Extension' | 'Tool';
export type PromptContentResponse = {
@@ -1972,6 +1974,22 @@ export type BackupConfigResponses = {
export type BackupConfigResponse = BackupConfigResponses[keyof BackupConfigResponses];
export type GetCanonicalModelInfoData = {
body: ModelInfoQuery;
path?: never;
query?: never;
url: '/config/canonical-model-info';
};
export type GetCanonicalModelInfoResponses = {
/**
* Model information retrieved successfully
*/
200: ModelInfoResponse;
};
export type GetCanonicalModelInfoResponse = GetCanonicalModelInfoResponses[keyof GetCanonicalModelInfoResponses];
export type CheckProviderData = {
body: CheckProviderRequest;
path?: never;
@@ -2245,22 +2263,6 @@ export type UpsertPermissionsResponses = {
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
export type GetPricingData = {
body: PricingQuery;
path?: never;
query?: never;
url: '/config/pricing';
};
export type GetPricingResponses = {
/**
* Model pricing data retrieved successfully
*/
200: PricingResponse;
};
export type GetPricingResponse = GetPricingResponses[keyof GetPricingResponses];
export type GetPromptsData = {
body?: never;
path?: never;
+31 -56
View File
@@ -41,6 +41,7 @@ import {
import { getNavigationShortcutText } from '../utils/keyboardShortcuts';
import { UserInput, ImageData } from '../types/message';
import { compressImageDataUrl } from '../utils/conversionUtils';
import { fetchCanonicalModelInfo } from '../utils/canonical';
interface PastedImage {
id: string;
@@ -58,11 +59,6 @@ const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning
// Manual compact trigger message - must match backend constant
const MANUAL_COMPACT_TRIGGER = '/compact';
interface ModelLimit {
pattern: string;
context_limit: number;
}
interface ChatInputProps {
sessionId: string | null;
handleSubmit: (input: UserInput) => void;
@@ -142,7 +138,7 @@ export default function ChatInput({
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
null
) as React.RefObject<HTMLDivElement>;
const { getProviders, read } = useConfig();
const { getProviders } = useConfig();
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
@@ -367,28 +363,6 @@ export default function ChatInput({
}
}, [textAreaRef]);
// Load model limits from the API
const getModelLimits = async () => {
try {
const response = await read('model-limits', false);
if (response) {
// The response is already parsed, no need for JSON.parse
return response as ModelLimit[];
}
} catch (err) {
console.error('Error fetching model limits:', err);
}
return [];
};
const findModelLimit = (modelName: string, modelLimits: ModelLimit[]): number | null => {
if (!modelName) return null;
const matchingLimit = modelLimits.find((limit) =>
modelName.toLowerCase().includes(limit.pattern.toLowerCase())
);
return matchingLimit ? matchingLimit.context_limit : null;
};
// Load providers and get current model's token limit
const loadProviderDetails = async () => {
try {
@@ -403,7 +377,7 @@ export default function ChatInput({
return;
}
// First, check predefined models from environment (highest priority)
// Priority 1: Check predefined models from environment
const predefinedModels = getPredefinedModelsFromEnv();
const predefinedModel = predefinedModels.find((m) => m.name === model);
if (predefinedModel?.context_limit) {
@@ -412,12 +386,18 @@ export default function ChatInput({
return;
}
const providers = await getProviders(true);
// Priority 2: Check canonical model info (source of truth)
const canonicalInfo = await fetchCanonicalModelInfo(provider, model);
if (canonicalInfo?.context_limit) {
setTokenLimit(canonicalInfo.context_limit);
setIsTokenLimitLoaded(true);
return;
}
// Find the provider details for the current provider
// Priority 3: Fall back to provider metadata known_models (may be outdated)
const providers = await getProviders(true);
const currentProvider = providers.find((p) => p.name === provider);
if (currentProvider?.metadata?.known_models) {
// Find the model's token limit from the backend response
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);
if (modelConfig?.context_limit) {
setTokenLimit(modelConfig.context_limit);
@@ -426,16 +406,7 @@ export default function ChatInput({
}
}
// Fallback: Use pattern matching logic if no exact model match was found
const modelLimit = await getModelLimits();
const fallbackLimit = findModelLimit(model as string, modelLimit);
if (fallbackLimit !== null) {
setTokenLimit(fallbackLimit);
setIsTokenLimitLoaded(true);
return;
}
// If no match found, use the default model limit
// Priority 4: Use default if nothing else found
setTokenLimit(TOKEN_LIMIT_DEFAULT);
setIsTokenLimitLoaded(true);
} catch (err) {
@@ -1190,11 +1161,13 @@ export default function ChatInput({
return (
<div
className={`flex flex-col relative h-auto p-4 transition-colors ${disableAnimation ? '' : 'page-transition'
} ${isFocused
className={`flex flex-col relative h-auto p-4 transition-colors ${
disableAnimation ? '' : 'page-transition'
} ${
isFocused
? 'border-border-strong hover:border-border-strong'
: 'border-border-default hover:border-border-default'
} bg-background-default z-10 rounded-t-2xl`}
} bg-background-default z-10 rounded-t-2xl`}
data-drop-zone="true"
onDrop={handleLocalDrop}
onDragOver={handleLocalDragOver}
@@ -1263,7 +1236,7 @@ export default function ChatInput({
size="sm"
shape="round"
variant="outline"
onClick={() => { }}
onClick={() => {}}
disabled={true}
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2"
>
@@ -1310,12 +1283,13 @@ export default function ChatInput({
}
}}
disabled={isTranscribing}
className={`rounded-full px-6 py-2 ${isRecording
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: isTranscribing
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
}`}
className={`rounded-full px-6 py-2 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: isTranscribing
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
}`}
>
<Microphone />
</Button>
@@ -1353,10 +1327,11 @@ export default function ChatInput({
shape="round"
variant="outline"
disabled={isSubmitButtonDisabled}
className={`rounded-full px-10 py-2 flex items-center gap-2 ${isSubmitButtonDisabled
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
}`}
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
isSubmitButtonDisabled
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
}`}
>
<Send className="w-4 h-4" />
<span className="text-sm">Send</span>
@@ -120,9 +120,7 @@ function McpAppWrapper({
const resultWithMeta = toolResponse?.toolResult as ToolResultWithMeta | undefined;
const toolResult =
resultWithMeta?.status === 'success' && resultWithMeta.value
? resultWithMeta.value
: undefined;
resultWithMeta?.status === 'success' && resultWithMeta.value ? resultWithMeta.value : undefined;
if (!resourceUri) return null;
if (requestWithMeta.toolCall.status !== 'success') return null;
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
import { useModelAndProvider } from '../ModelAndProviderContext';
import { CoinIcon } from '../icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { fetchModelPricing } from '../../utils/pricing';
import { PricingData } from '../../api';
import { fetchCanonicalModelInfo } from '../../utils/canonical';
import type { ModelInfoData } from '../../api';
interface CostTrackerProps {
inputTokens?: number;
@@ -19,7 +19,7 @@ interface CostTrackerProps {
export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) {
const { currentModel, currentProvider } = useModelAndProvider();
const [costInfo, setCostInfo] = useState<PricingData | null>(null);
const [costInfo, setCostInfo] = useState<ModelInfoData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showPricing, setShowPricing] = useState(true);
const [pricingFailed, setPricingFailed] = useState(false);
@@ -45,7 +45,7 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
setIsLoading(true);
try {
const costData = await fetchModelPricing(currentProvider, currentModel);
const costData = await fetchCanonicalModelInfo(currentProvider, currentModel);
if (costData) {
setCostInfo(costData);
setPricingFailed(false);
@@ -84,8 +84,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
costInfo &&
(costInfo.input_token_cost !== undefined || costInfo.output_token_cost !== undefined)
) {
const currentInputCost = inputTokens * (costInfo.input_token_cost || 0);
const currentOutputCost = outputTokens * (costInfo.output_token_cost || 0);
const currentInputCost = (inputTokens * (costInfo.input_token_cost || 0)) / 1_000_000;
const currentOutputCost = (outputTokens * (costInfo.output_token_cost || 0)) / 1_000_000;
totalCost += currentInputCost + currentOutputCost;
}
@@ -100,8 +100,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
return 0;
}
const inputCost = inputTokens * (costInfo.input_token_cost || 0);
const outputCost = outputTokens * (costInfo.output_token_cost || 0);
const inputCost = (inputTokens * (costInfo.input_token_cost || 0)) / 1_000_000;
const outputCost = (outputTokens * (costInfo.output_token_cost || 0)) / 1_000_000;
const total = inputCost + outputCost;
return total;
@@ -201,8 +201,9 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
// Add current model if it has costs
if (costInfo && (inputTokens > 0 || outputTokens > 0)) {
const currentCost =
inputTokens * (costInfo.input_token_cost || 0) +
outputTokens * (costInfo.output_token_cost || 0);
(inputTokens * (costInfo.input_token_cost || 0) +
outputTokens * (costInfo.output_token_cost || 0)) /
1_000_000;
if (currentCost > 0) {
tooltip += `${currentProvider}/${currentModel} (current): ${costInfo.currency || '$'}${currentCost.toFixed(6)} (${inputTokens.toLocaleString()} in, ${outputTokens.toLocaleString()} out)\n`;
}
@@ -213,7 +214,7 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
}
// Default tooltip for single model
return `Input: ${inputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${(inputTokens * (costInfo?.input_token_cost || 0)).toFixed(6)}) | Output: ${outputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${(outputTokens * (costInfo?.output_token_cost || 0)).toFixed(6)})`;
return `Input: ${inputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}) | Output: ${outputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)})`;
};
return (
@@ -358,15 +358,17 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
const resp = await searchSessions({
query: { query: debouncedSearchTerm },
});
if (resp.data) {
// Response is Vec<Session> - sessions that match the search
const matchedSessionIds = new Set(resp.data.map((s: { id: string }) => s.id));
const filtered = sessions.filter((session) => matchedSessionIds.has(session.id));
startTransition(() => {
setFilteredSessions(filtered);
setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null);
setSearchResults(
filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null
);
});
}
};
@@ -163,7 +163,7 @@ export default function ExtensionModal({
const trimmedNewKey = value.trim();
const normalizedNewKey = trimmedNewKey.toLowerCase();
const isDuplicate = formData.headers.some(
(h, i) => i !== index && h.key.trim().toLowerCase() === normalizedNewKey,
(h, i) => i !== index && h.key.trim().toLowerCase() === normalizedNewKey
);
if (isDuplicate && trimmedNewKey !== '') {
return;
@@ -45,9 +45,7 @@ export default function HeadersSection({
const valueEmpty = !newValue.trim();
const keyHasSpaces = newKey.includes(' ');
const normalizedNewKey = newKey.trim().toLowerCase();
const isDuplicate = headers.some(
h => h.key.trim().toLowerCase() === normalizedNewKey
);
const isDuplicate = headers.some((h) => h.key.trim().toLowerCase() === normalizedNewKey);
if (keyEmpty || valueEmpty) {
setInvalidFields({
@@ -30,10 +30,7 @@ export default function ModelsBottomBar({
setView,
alerts,
}: ModelsBottomBarProps) {
const {
currentModel,
currentProvider,
} = useModelAndProvider();
const { currentModel, currentProvider } = useModelAndProvider();
const currentModelInfo = useCurrentModelInfo();
const { read, getProviders } = useConfig();
const [displayProvider, setDisplayProvider] = useState<string | null>(null);
@@ -85,7 +85,9 @@ export const SwitchModelModal = ({
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
type ModelOption = { value: string; label: string; provider: string; isDisabled?: boolean };
const [modelOptions, setModelOptions] = useState<{ options: ModelOption[] }[]>([]);
const [provider, setProvider] = useState<string | null>(initialProvider || currentProvider || null);
const [provider, setProvider] = useState<string | null>(
initialProvider || currentProvider || null
);
const [model, setModel] = useState<string>(currentModel || '');
const [isCustomModel, setIsCustomModel] = useState(false);
const [validationErrors, setValidationErrors] = useState({
@@ -78,7 +78,7 @@ export default function CustomProviderForm({
const valueEmpty = !newHeaderValue.trim();
const keyHasSpaces = newHeaderKey.includes(' ');
const normalizedNewKey = newHeaderKey.trim().toLowerCase();
const isDuplicate = headers.some(h => h.key.trim().toLowerCase() === normalizedNewKey);
const isDuplicate = headers.some((h) => h.key.trim().toLowerCase() === normalizedNewKey);
if (keyEmpty || valueEmpty) {
setInvalidHeaderFields({
@@ -125,7 +125,7 @@ export default function CustomProviderForm({
}
const normalizedValue = value.trim().toLowerCase();
const isDuplicate = headers.some(
(h, i) => i !== index && h.key.trim().toLowerCase() === normalizedValue,
(h, i) => i !== index && h.key.trim().toLowerCase() === normalizedValue
);
if (isDuplicate && normalizedValue !== '') {
return;
@@ -177,9 +177,7 @@ export default function CustomProviderForm({
if (newHeaderKey.trim() && newHeaderValue.trim()) {
const keyHasSpaces = newHeaderKey.includes(' ');
const normalizedPendingKey = newHeaderKey.trim().toLowerCase();
const isDuplicate = headers.some(
(h) => h.key.trim().toLowerCase() === normalizedPendingKey,
);
const isDuplicate = headers.some((h) => h.key.trim().toLowerCase() === normalizedPendingKey);
if (!keyHasSpaces && !isDuplicate) {
allHeaders.push({ key: newHeaderKey, value: newHeaderValue });
@@ -387,7 +385,8 @@ export default function CustomProviderForm({
Custom Headers
</label>
<p className="text-xs text-textSubtle mb-4">
Add custom HTTP headers to include in requests to the provider. Click the "+" button to add after filling both fields.
Add custom HTTP headers to include in requests to the provider. Click the "+" button
to add after filling both fields.
</p>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
{headers.map((header, index) => (
+9 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useModelAndProvider } from '../components/ModelAndProviderContext';
import { fetchModelPricing } from '../utils/pricing';
import { fetchCanonicalModelInfo } from '../utils/canonical';
import { Session } from '../api';
interface UseCostTrackingProps {
@@ -42,13 +42,18 @@ export const useCostTracking = ({
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
// Get pricing info for the previous model
const prevCostInfo = await fetchModelPricing(prevProviderRef.current, prevModelRef.current);
const prevCostInfo = await fetchCanonicalModelInfo(
prevProviderRef.current,
prevModelRef.current
);
if (prevCostInfo) {
const prevInputCost =
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
((sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0)) /
1_000_000;
const prevOutputCost =
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
((sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0)) /
1_000_000;
const prevTotalCost = prevInputCost + prevOutputCost;
// Save the accumulated costs for this model
+24
View File
@@ -0,0 +1,24 @@
/**
* Utilities for fetching canonical model information from the backend
*/
import { getCanonicalModelInfo, type ModelInfoData } from '../api';
/**
* Fetch canonical model info (pricing + context limits) for a specific provider/model
*/
export async function fetchCanonicalModelInfo(
provider: string,
model: string
): Promise<ModelInfoData | null> {
try {
const response = await getCanonicalModelInfo({
body: { provider, model },
throwOnError: true,
});
return response.data.model_info ?? null;
} catch {
return null;
}
}
-24
View File
@@ -1,24 +0,0 @@
import { getPricing, PricingData } from '../api';
/**
* Fetch pricing for a specific provider/model from the backend
*/
export async function fetchModelPricing(
provider: string,
model: string
): Promise<PricingData | null> {
try {
const response = await getPricing({
body: { provider, model },
throwOnError: false,
});
if (!response.data) {
return null;
}
return response.data.pricing?.[0] ?? null;
} catch {
return null;
}
}