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
+113 -102
View File
@@ -741,6 +741,36 @@
}
}
},
"/config/canonical-model-info": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_canonical_model_info",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelInfoQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Model information retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelInfoResponse"
}
}
}
}
}
}
},
"/config/check_provider": {
"post": {
"tags": [
@@ -1095,36 +1125,6 @@
}
}
},
"/config/pricing": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_pricing",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PricingQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Model pricing data retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PricingResponse"
}
}
}
}
}
}
},
"/config/prompts": {
"get": {
"tags": [
@@ -5261,10 +5261,6 @@
"nullable": true,
"minimum": 0
},
"fast_model": {
"type": "string",
"nullable": true
},
"max_tokens": {
"type": "integer",
"format": "int32",
@@ -5334,6 +5330,89 @@
}
}
},
"ModelInfoData": {
"type": "object",
"required": [
"provider",
"model",
"context_limit",
"currency"
],
"properties": {
"cache_read_token_cost": {
"type": "number",
"format": "double",
"nullable": true
},
"cache_write_token_cost": {
"type": "number",
"format": "double",
"nullable": true
},
"context_limit": {
"type": "integer",
"minimum": 0
},
"currency": {
"type": "string"
},
"input_token_cost": {
"type": "number",
"format": "double",
"nullable": true
},
"max_output_tokens": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"model": {
"type": "string"
},
"output_token_cost": {
"type": "number",
"format": "double",
"nullable": true
},
"provider": {
"type": "string"
}
}
},
"ModelInfoQuery": {
"type": "object",
"required": [
"provider",
"model"
],
"properties": {
"model": {
"type": "string"
},
"provider": {
"type": "string"
}
}
},
"ModelInfoResponse": {
"type": "object",
"required": [
"source"
],
"properties": {
"model_info": {
"allOf": [
{
"$ref": "#/components/schemas/ModelInfoData"
}
],
"nullable": true
},
"source": {
"type": "string"
}
}
},
"ParseRecipeRequest": {
"type": "object",
"required": [
@@ -5397,74 +5476,6 @@
}
}
},
"PricingData": {
"type": "object",
"required": [
"provider",
"model",
"input_token_cost",
"output_token_cost",
"currency"
],
"properties": {
"context_length": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"currency": {
"type": "string"
},
"input_token_cost": {
"type": "number",
"format": "double"
},
"model": {
"type": "string"
},
"output_token_cost": {
"type": "number",
"format": "double"
},
"provider": {
"type": "string"
}
}
},
"PricingQuery": {
"type": "object",
"required": [
"provider",
"model"
],
"properties": {
"model": {
"type": "string"
},
"provider": {
"type": "string"
}
}
},
"PricingResponse": {
"type": "object",
"required": [
"pricing",
"source"
],
"properties": {
"pricing": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PricingData"
}
},
"source": {
"type": "string"
}
}
},
"PrincipalType": {
"type": "string",
"enum": [
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;
}
}