feat(web): add context window support to dashboard config

- Add GET /api/model/info endpoint that resolves model metadata using the
  same 10-step context-length detection chain the agent uses. Returns
  auto-detected context length, config override, effective value, and
  model capabilities (tools, vision, reasoning, max output, model family).

- Surface model.context_length as model_context_length virtual field in
  the config normalize/denormalize cycle. 0 = auto-detect (default),
  positive value overrides. Writing 0 removes context_length from the
  model dict on disk.

- Add ModelInfoCard component showing resolved context window (e.g. '1M
  auto-detected' or '500K override — auto: 1M'), max output tokens, and
  colored capability badges (Tools, Vision, Reasoning, model family).

- Inject ModelInfoCard between model field and context_length override in
  ConfigPage General tab. Card re-fetches on model change and after save.

- Insert model_context_length right after model in CONFIG_SCHEMA ordering
  so the three elements (model input → info card → override) are adjacent.
This commit is contained in:
kshitijk4poor 2026-04-14 08:25:09 +05:30 committed by Teknium
parent eabc0a2f66
commit 8fd3093f49
6 changed files with 293 additions and 5 deletions

View file

@ -96,6 +96,11 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)", "description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
"category": "general", "category": "general",
}, },
"model_context_length": {
"type": "number",
"description": "Context window override (0 = auto-detect from model metadata)",
"category": "general",
},
"terminal.backend": { "terminal.backend": {
"type": "select", "type": "select",
"description": "Terminal execution backend", "description": "Terminal execution backend",
@ -246,6 +251,17 @@ def _build_schema_from_config(
CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG) CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
# Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced
# by the normalize/denormalize cycle. Insert model_context_length right after
# the "model" key so it renders adjacent in the frontend.
_mcl_entry = _SCHEMA_OVERRIDES["model_context_length"]
_ordered_schema: Dict[str, Dict[str, Any]] = {}
for _k, _v in CONFIG_SCHEMA.items():
_ordered_schema[_k] = _v
if _k == "model":
_ordered_schema["model_context_length"] = _mcl_entry
CONFIG_SCHEMA = _ordered_schema
class ConfigUpdate(BaseModel): class ConfigUpdate(BaseModel):
config: dict config: dict
@ -408,11 +424,19 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
dict form. Normalize to the string form so the frontend schema matches. dict form. Normalize to the string form so the frontend schema matches.
Also surfaces ``model_context_length`` as a top-level field so the web UI can
display and edit it. A value of 0 means "auto-detect".
""" """
config = dict(config) # shallow copy config = dict(config) # shallow copy
model_val = config.get("model") model_val = config.get("model")
if isinstance(model_val, dict): if isinstance(model_val, dict):
# Extract context_length before flattening the dict
ctx_len = model_val.get("context_length", 0)
config["model"] = model_val.get("default", model_val.get("name", "")) config["model"] = model_val.get("default", model_val.get("name", ""))
config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0
else:
config["model_context_length"] = 0
return config return config
@ -433,6 +457,93 @@ async def get_schema():
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER} return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
_EMPTY_MODEL_INFO: dict = {
"model": "",
"provider": "",
"auto_context_length": 0,
"config_context_length": 0,
"effective_context_length": 0,
"capabilities": {},
}
@app.get("/api/model/info")
def get_model_info():
"""Return resolved model metadata for the currently configured model.
Calls the same context-length resolution chain the agent uses, so the
frontend can display "Auto-detected: 200K" alongside the override field.
Also returns model capabilities (vision, reasoning, tools) when available.
"""
try:
cfg = load_config()
model_cfg = cfg.get("model", "")
# Extract model name and provider from the config
if isinstance(model_cfg, dict):
model_name = model_cfg.get("default", model_cfg.get("name", ""))
provider = model_cfg.get("provider", "")
base_url = model_cfg.get("base_url", "")
config_ctx = model_cfg.get("context_length")
else:
model_name = str(model_cfg) if model_cfg else ""
provider = ""
base_url = ""
config_ctx = None
if not model_name:
return dict(_EMPTY_MODEL_INFO, provider=provider)
# Resolve auto-detected context length (pass config_ctx=None to get
# purely auto-detected value, then separately report the override)
try:
from agent.model_metadata import get_model_context_length
auto_ctx = get_model_context_length(
model=model_name,
base_url=base_url,
provider=provider,
config_context_length=None, # ignore override — we want auto value
)
except Exception:
auto_ctx = 0
config_ctx_int = 0
if isinstance(config_ctx, int) and config_ctx > 0:
config_ctx_int = config_ctx
# Effective is what the agent actually uses
effective_ctx = config_ctx_int if config_ctx_int > 0 else auto_ctx
# Try to get model capabilities from models.dev
caps = {}
try:
from agent.models_dev import get_model_capabilities
mc = get_model_capabilities(provider=provider, model=model_name)
if mc is not None:
caps = {
"supports_tools": mc.supports_tools,
"supports_vision": mc.supports_vision,
"supports_reasoning": mc.supports_reasoning,
"context_window": mc.context_window,
"max_output_tokens": mc.max_output_tokens,
"model_family": mc.model_family,
}
except Exception:
pass
return {
"model": model_name,
"provider": provider,
"auto_context_length": auto_ctx,
"config_context_length": config_ctx_int,
"effective_context_length": effective_ctx,
"capabilities": caps,
}
except Exception:
_log.exception("GET /api/model/info failed")
return dict(_EMPTY_MODEL_INFO)
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
"""Reverse _normalize_config_for_web before saving. """Reverse _normalize_config_for_web before saving.
@ -440,12 +551,24 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
to recover model subkeys (provider, base_url, api_mode, etc.) that were to recover model subkeys (provider, base_url, api_mode, etc.) that were
stripped from the GET response. The frontend only sees model as a flat stripped from the GET response. The frontend only sees model as a flat
string; the rest is preserved transparently. string; the rest is preserved transparently.
Also handles ``model_context_length`` writes it back into the model dict
as ``context_length``. A value of 0 or absent means "auto-detect" (omitted
from the dict so get_model_context_length() uses its normal resolution).
""" """
config = dict(config) config = dict(config)
# Remove any _model_meta that might have leaked in (shouldn't happen # Remove any _model_meta that might have leaked in (shouldn't happen
# with the stripped GET response, but be defensive) # with the stripped GET response, but be defensive)
config.pop("_model_meta", None) config.pop("_model_meta", None)
# Extract and remove model_context_length before processing model
ctx_override = config.pop("model_context_length", 0)
if not isinstance(ctx_override, int):
try:
ctx_override = int(ctx_override)
except (TypeError, ValueError):
ctx_override = 0
model_val = config.get("model") model_val = config.get("model")
if isinstance(model_val, str) and model_val: if isinstance(model_val, str) and model_val:
# Read the current disk config to recover model subkeys # Read the current disk config to recover model subkeys
@ -455,7 +578,20 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(disk_model, dict): if isinstance(disk_model, dict):
# Preserve all subkeys, update default with the new value # Preserve all subkeys, update default with the new value
disk_model["default"] = model_val disk_model["default"] = model_val
# Write context_length into the model dict (0 = remove/auto)
if ctx_override > 0:
disk_model["context_length"] = ctx_override
else:
disk_model.pop("context_length", None)
config["model"] = disk_model config["model"] = disk_model
else:
# Model was previously a bare string — upgrade to dict if
# user is setting a context_length override
if ctx_override > 0:
config["model"] = {
"default": model_val,
"context_length": ctx_override,
}
except Exception: except Exception:
pass # can't read disk config — just use the string form pass # can't read disk config — just use the string form
return config return config

View file

@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from "react";
import {
Brain,
Eye,
Gauge,
Lightbulb,
Wrench,
Loader2,
} from "lucide-react";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
interface ModelInfoCardProps {
/** Current model string from config state — used to detect changes */
currentModel: string;
/** Bumped after config saves to trigger re-fetch */
refreshKey?: number;
}
export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardProps) {
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
if (!currentModel) return;
// Re-fetch when model changes OR when refreshKey bumps (after save)
const fetchKey = `${currentModel}:${refreshKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
setLoading(true);
api
.getModelInfo()
.then(setInfo)
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [currentModel, refreshKey]);
if (loading) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Loading model info
</div>
);
}
if (!info || !info.model) return null;
const caps = info.capabilities;
const hasCaps = caps && Object.keys(caps).length > 0;
return (
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
{/* Context window */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
<span className="font-medium">Context Window</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500/80 text-[10px]">
(override auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-muted-foreground/60 text-[10px]">auto-detected</span>
)}
</div>
</div>
{/* Max output */}
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lightbulb className="h-3.5 w-3.5" />
<span className="font-medium">Max Output</span>
</div>
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(caps.max_output_tokens)}
</span>
</div>
)}
{/* Capability badges */}
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{caps.supports_vision && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{caps.supports_reasoning && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{caps.model_family && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{caps.model_family}
</span>
)}
</div>
)}
</div>
);
}

View file

@ -43,6 +43,7 @@ export const api = {
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"), getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"), getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"), getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
saveConfig: (config: Record<string, unknown>) => saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", { fetchJSON<{ ok: boolean }>("/api/config", {
method: "PUT", method: "PUT",
@ -325,6 +326,24 @@ export interface SessionSearchResponse {
results: SessionSearchResult[]; results: SessionSearchResult[];
} }
// ── Model info types ──────────────────────────────────────────────────
export interface ModelInfoResponse {
model: string;
provider: string;
auto_context_length: number;
config_context_length: number;
effective_context_length: number;
capabilities: {
supports_tools?: boolean;
supports_vision?: boolean;
supports_reasoning?: boolean;
context_window?: number;
max_output_tokens?: number;
model_family?: string;
};
}
// ── OAuth provider types ──────────────────────────────────────────────── // ── OAuth provider types ────────────────────────────────────────────────
export interface OAuthProviderStatus { export interface OAuthProviderStatus {

9
web/src/lib/format.ts Normal file
View file

@ -0,0 +1,9 @@
/**
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
* Strips trailing ".0" for clean round numbers.
*/
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return String(n);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { formatTokenCount } from "@/lib/format";
import { import {
BarChart3, BarChart3,
Cpu, Cpu,
@ -18,11 +19,7 @@ const PERIODS = [
const CHART_HEIGHT_PX = 160; const CHART_HEIGHT_PX = 160;
function formatTokens(n: number): string { const formatTokens = formatTokenCount;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatDate(day: string): string { function formatDate(day: string): string {
try { try {

View file

@ -32,6 +32,7 @@ import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast"; import { Toast } from "@/components/Toast";
import { AutoField } from "@/components/AutoField"; import { AutoField } from "@/components/AutoField";
import { ModelInfoCard } from "@/components/ModelInfoCard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -87,6 +88,7 @@ export default function ConfigPage() {
const [yamlLoading, setYamlLoading] = useState(false); const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false); const [yamlSaving, setYamlSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>(""); const [activeCategory, setActiveCategory] = useState<string>("");
const [modelInfoRefreshKey, setModelInfoRefreshKey] = useState(0);
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -174,6 +176,7 @@ export default function ConfigPage() {
try { try {
await api.saveConfig(config); await api.saveConfig(config);
showToast("Configuration saved", "success"); showToast("Configuration saved", "success");
setModelInfoRefreshKey((k) => k + 1);
} catch (e) { } catch (e) {
showToast(`Failed to save: ${e}`, "error"); showToast(`Failed to save: ${e}`, "error");
} finally { } finally {
@ -186,6 +189,7 @@ export default function ConfigPage() {
try { try {
await api.saveConfigRaw(yamlText); await api.saveConfigRaw(yamlText);
showToast("YAML config saved", "success"); showToast("YAML config saved", "success");
setModelInfoRefreshKey((k) => k + 1);
api.getConfig().then(setConfig).catch(() => {}); api.getConfig().then(setConfig).catch(() => {});
} catch (e) { } catch (e) {
showToast(`Failed to save YAML: ${e}`, "error"); showToast(`Failed to save YAML: ${e}`, "error");
@ -238,6 +242,7 @@ export default function ConfigPage() {
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => { const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
let lastSection = ""; let lastSection = "";
let lastCat = ""; let lastCat = "";
const currentModel = config ? String(getNestedValue(config, "model") ?? "") : "";
return fields.map(([key, s]) => { return fields.map(([key, s]) => {
const parts = key.split("."); const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : ""; const section = parts.length > 1 ? parts[0] : "";
@ -274,6 +279,12 @@ export default function ConfigPage() {
onChange={(v) => setConfig(setNestedValue(config, key, v))} onChange={(v) => setConfig(setNestedValue(config, key, v))}
/> />
</div> </div>
{/* Inject model info card right after the model field */}
{key === "model" && currentModel && (
<div className="py-1">
<ModelInfoCard currentModel={currentModel} refreshKey={modelInfoRefreshKey} />
</div>
)}
</div> </div>
); );
}); });