Compare commits
No commits in common. "main" and "feat/session-status-indicators" have entirely different histories.
main
...
feat/sessi
7 changed files with 98 additions and 1594 deletions
|
|
@ -17,7 +17,7 @@ import crypto from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { CLAUDE_MODELS, getModelContextWindow } from '../shared/modelConstants.js';
|
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||||
import {
|
import {
|
||||||
createNotificationEvent,
|
createNotificationEvent,
|
||||||
|
|
@ -205,14 +205,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||||
|
// Model logged at query start below
|
||||||
// Stamp per-model CONTEXT_WINDOW into the subprocess env so Claude Code
|
|
||||||
// self-limits its prompt budget to the model's actual capacity.
|
|
||||||
// Local/constrained models (e.g. claudecode, cc/qwen72b) declare a lower
|
|
||||||
// ceiling in MODEL_CONTEXT_OVERRIDES; cloud models use the global default.
|
|
||||||
const modelContextWindow = getModelContextWindow(sdkOptions.model);
|
|
||||||
sdkOptions.env.CONTEXT_WINDOW = String(modelContextWindow);
|
|
||||||
sdkOptions.env.VITE_CONTEXT_WINDOW = String(modelContextWindow);
|
|
||||||
|
|
||||||
// Map system prompt configuration
|
// Map system prompt configuration
|
||||||
sdkOptions.systemPrompt = {
|
sdkOptions.systemPrompt = {
|
||||||
|
|
@ -294,10 +287,9 @@ function transformMessage(sdkMessage) {
|
||||||
/**
|
/**
|
||||||
* Extracts token usage from SDK result messages
|
* Extracts token usage from SDK result messages
|
||||||
* @param {Object} resultMessage - SDK result message
|
* @param {Object} resultMessage - SDK result message
|
||||||
* @param {string} modelValue - The model value used for this session
|
|
||||||
* @returns {Object|null} Token budget object or null
|
* @returns {Object|null} Token budget object or null
|
||||||
*/
|
*/
|
||||||
function extractTokenBudget(resultMessage, modelValue) {
|
function extractTokenBudget(resultMessage) {
|
||||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -320,8 +312,11 @@ function extractTokenBudget(resultMessage, modelValue) {
|
||||||
// Total used = input + output + cache tokens
|
// Total used = input + output + cache tokens
|
||||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
||||||
|
|
||||||
// Use per-model context window so the budget meter reflects the model's actual ceiling.
|
// Use configured context window budget from environment (default 160000)
|
||||||
const contextWindow = getModelContextWindow(modelValue);
|
// This is the user's budget limit, not the model's context window
|
||||||
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||||
|
|
||||||
|
// Token calc logged via token-budget WS event
|
||||||
|
|
||||||
return {
|
return {
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
|
|
@ -687,7 +682,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||||
if (models.length > 0) {
|
if (models.length > 0) {
|
||||||
// Model info available in result message
|
// Model info available in result message
|
||||||
}
|
}
|
||||||
const tokenBudgetData = extractTokenBudget(message, sdkOptions.model);
|
const tokenBudgetData = extractTokenBudget(message);
|
||||||
if (tokenBudgetData) {
|
if (tokenBudgetData) {
|
||||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1429
server/index.js
1429
server/index.js
File diff suppressed because it is too large
Load diff
|
|
@ -17,8 +17,8 @@ let cacheExpiry = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a raw /v1/models entry to the { value, label } shape used by the UI.
|
* Maps a raw /v1/models entry to the { value, label } shape used by the UI.
|
||||||
* Accepts all models returned by the endpoint — 9router surfaces only what it
|
* Filters to models that are relevant for Claude routing (id contains 'claude'
|
||||||
* actually routes, so no client-side filtering is needed.
|
* or starts with 'cc/').
|
||||||
*
|
*
|
||||||
* @param {Object} entry - Raw model object from /v1/models
|
* @param {Object} entry - Raw model object from /v1/models
|
||||||
* @returns {{ value: string, label: string } | null}
|
* @returns {{ value: string, label: string } | null}
|
||||||
|
|
@ -27,8 +27,14 @@ function mapModelEntry(entry) {
|
||||||
const id = typeof entry?.id === 'string' ? entry.id.trim() : null;
|
const id = typeof entry?.id === 'string' ? entry.id.trim() : null;
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|
||||||
// Build a human-readable label: prefer server-supplied name, then id.
|
// Only surface Claude-family and cc/* (9router) models for the Claude provider.
|
||||||
// cc/* entries strip the prefix and add a "(9router)" suffix for clarity.
|
// Extend this filter if you want to surface all models.
|
||||||
|
const isClaude = id.toLowerCase().includes('claude') || id.startsWith('cc/');
|
||||||
|
if (!isClaude) return null;
|
||||||
|
|
||||||
|
// Build a human-readable label from the id.
|
||||||
|
// e.g. "cc/claude-sonnet-4-6" → "claude-sonnet-4-6 (9router)"
|
||||||
|
// "claude-3-5-sonnet-20241022" → "claude-3-5-sonnet-20241022"
|
||||||
let label = entry.name ?? id;
|
let label = entry.name ?? id;
|
||||||
if (id.startsWith('cc/')) {
|
if (id.startsWith('cc/')) {
|
||||||
label = `${id.slice(3)} (9router)`;
|
label = `${id.slice(3)} (9router)`;
|
||||||
|
|
@ -74,7 +80,7 @@ async function fetchModelsFromApi() {
|
||||||
const mapped = raw.map(mapModelEntry).filter(Boolean);
|
const mapped = raw.map(mapModelEntry).filter(Boolean);
|
||||||
if (mapped.length === 0) return null;
|
if (mapped.length === 0) return null;
|
||||||
|
|
||||||
console.log(`[model-discovery] Loaded ${mapped.length} models from ${url}`);
|
console.log(`[model-discovery] Loaded ${mapped.length} Claude models from ${url}`);
|
||||||
return mapped;
|
return mapped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[model-discovery] Failed to fetch ${url}:`, err?.message ?? err);
|
console.warn(`[model-discovery] Failed to fetch ${url}:`, err?.message ?? err);
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@ export const CLAUDE_MODELS = {
|
||||||
{ value: "ollama/glm-4.7-flash", label: "GLM-4.7 Flash (ollama)" },
|
{ value: "ollama/glm-4.7-flash", label: "GLM-4.7 Flash (ollama)" },
|
||||||
{ value: "ollama/qwen3.5", label: "Qwen 3.5 (ollama)" },
|
{ value: "ollama/qwen3.5", label: "Qwen 3.5 (ollama)" },
|
||||||
{ value: "ollama-local/r1-14b-32k", label: "R1 14B 32K (local)" },
|
{ value: "ollama-local/r1-14b-32k", label: "R1 14B 32K (local)" },
|
||||||
{ value: "claudecode", label: "claudecode (local)" },
|
|
||||||
{ value: "cc/qwen72b", label: "qwen72b (local)" },
|
|
||||||
{ value: "gemini/gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
{ value: "gemini/gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||||
{ value: "gemini/gemini-3.1-flash-lite-preview", label: "Gemini 3.1 Flash Lite" },
|
{ value: "gemini/gemini-3.1-flash-lite-preview", label: "Gemini 3.1 Flash Lite" },
|
||||||
{ value: "gemini/gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
{ value: "gemini/gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||||
|
|
@ -106,37 +104,6 @@ export const CLAUDE_MODELS = {
|
||||||
DEFAULT: "cc/claude-sonnet-4-6",
|
DEFAULT: "cc/claude-sonnet-4-6",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-model context window overrides (in tokens).
|
|
||||||
*
|
|
||||||
* Models listed here will have CONTEXT_WINDOW stamped into the Claude Code
|
|
||||||
* subprocess env so the agent self-limits its prompt budget accordingly.
|
|
||||||
* Models NOT listed here fall back to the global CONTEXT_WINDOW env var (default 160000).
|
|
||||||
*
|
|
||||||
* Add an entry here whenever you add a local/constrained model whose VRAM or
|
|
||||||
* architecture limits the usable context below the global default.
|
|
||||||
*/
|
|
||||||
export const MODEL_CONTEXT_OVERRIDES = {
|
|
||||||
'claudecode': 60000,
|
|
||||||
'cc/qwen72b': 60000,
|
|
||||||
'ollama-local/r1-14b-32k': 32000,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective context window size for a given model value.
|
|
||||||
* Checks MODEL_CONTEXT_OVERRIDES first, then falls back to the
|
|
||||||
* global CONTEXT_WINDOW env var, then 160000.
|
|
||||||
*
|
|
||||||
* @param {string} modelValue - The model value string (e.g. "claudecode", "cc/claude-sonnet-4-6")
|
|
||||||
* @returns {number} Context window in tokens
|
|
||||||
*/
|
|
||||||
export function getModelContextWindow(modelValue) {
|
|
||||||
if (modelValue && MODEL_CONTEXT_OVERRIDES[modelValue] !== undefined) {
|
|
||||||
return MODEL_CONTEXT_OVERRIDES[modelValue];
|
|
||||||
}
|
|
||||||
return parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cursor Models
|
* Cursor Models
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,18 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
load('gemini', setGeminiModelState, GEMINI_MODELS.DEFAULT);
|
load('gemini', setGeminiModelState, GEMINI_MODELS.DEFAULT);
|
||||||
}, [selectedSession?.id]);
|
}, [selectedSession?.id]);
|
||||||
|
|
||||||
// Fetch and apply live Claude model list, validating the current selection.
|
// Fetch live Claude model list and validate the current claude model
|
||||||
const fetchAndSetClaudeModels = useCallback(async () => {
|
useEffect(() => {
|
||||||
const res = await authenticatedFetch('/api/models');
|
authenticatedFetch('/api/models')
|
||||||
|
.then((res) => {
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
if (!Array.isArray(data?.claude) || data.claude.length === 0) return;
|
if (!Array.isArray(data?.claude) || data.claude.length === 0) return;
|
||||||
const options: ModelOption[] = data.claude;
|
const options: ModelOption[] = data.claude;
|
||||||
setClaudeModelOptions(options);
|
setClaudeModelOptions(options);
|
||||||
|
|
||||||
setClaudeModelState((current) => {
|
setClaudeModelState((current) => {
|
||||||
const valid = options.some((o) => o.value === current);
|
const valid = options.some((o) => o.value === current);
|
||||||
if (valid) return current;
|
if (valid) return current;
|
||||||
|
|
@ -108,17 +112,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
});
|
});
|
||||||
}, [selectedSession?.id]);
|
})
|
||||||
|
.catch(() => {
|
||||||
// Bust the server-side cache then re-fetch the model list.
|
|
||||||
const refreshClaudeModels = useCallback(async () => {
|
|
||||||
await authenticatedFetch('/api/models/refresh', { method: 'POST' });
|
|
||||||
await fetchAndSetClaudeModels();
|
|
||||||
}, [fetchAndSetClaudeModels]);
|
|
||||||
|
|
||||||
// Fetch live Claude model list on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAndSetClaudeModels().catch(() => {
|
|
||||||
// Static fallback already in place
|
// Static fallback already in place
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -214,6 +209,5 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
cyclePermissionMode,
|
cyclePermissionMode,
|
||||||
refreshClaudeModels,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ function ChatInterface({
|
||||||
pendingPermissionRequests,
|
pendingPermissionRequests,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
cyclePermissionMode,
|
cyclePermissionMode,
|
||||||
refreshClaudeModels,
|
|
||||||
} = useChatProviderState({
|
} = useChatProviderState({
|
||||||
selectedSession,
|
selectedSession,
|
||||||
});
|
});
|
||||||
|
|
@ -320,7 +319,6 @@ function ChatInterface({
|
||||||
codexModelOptions={codexModelOptions}
|
codexModelOptions={codexModelOptions}
|
||||||
geminiModelOptions={geminiModelOptions}
|
geminiModelOptions={geminiModelOptions}
|
||||||
cursorModelOptions={cursorModelOptions}
|
cursorModelOptions={cursorModelOptions}
|
||||||
onRefreshModels={refreshClaudeModels}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatMessagesPane
|
<ChatMessagesPane
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { ChevronDown, Globe, RefreshCw } from 'lucide-react';
|
import { ChevronDown, Globe } from 'lucide-react';
|
||||||
import type { LLMProvider } from '../../../../types/app';
|
import type { LLMProvider } from '../../../../types/app';
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
|
|
||||||
|
|
@ -19,7 +19,6 @@ interface ModelSelectorBarProps {
|
||||||
codexModelOptions: ModelOption[];
|
codexModelOptions: ModelOption[];
|
||||||
geminiModelOptions: ModelOption[];
|
geminiModelOptions: ModelOption[];
|
||||||
cursorModelOptions: ModelOption[];
|
cursorModelOptions: ModelOption[];
|
||||||
onRefreshModels?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCurrentModel(
|
function useCurrentModel(
|
||||||
|
|
@ -58,11 +57,9 @@ export default function ModelSelectorBar({
|
||||||
codexModelOptions,
|
codexModelOptions,
|
||||||
geminiModelOptions,
|
geminiModelOptions,
|
||||||
cursorModelOptions,
|
cursorModelOptions,
|
||||||
onRefreshModels,
|
|
||||||
}: ModelSelectorBarProps) {
|
}: ModelSelectorBarProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -104,16 +101,6 @@ export default function ModelSelectorBar({
|
||||||
setSearch('');
|
setSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
if (!onRefreshModels || isRefreshing) return;
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
await onRefreshModels();
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-40 flex-shrink-0 flex items-center justify-between px-4 py-1.5 border-b border-border/40 bg-muted/20 backdrop-blur-sm">
|
<div className="relative z-40 flex-shrink-0 flex items-center justify-between px-4 py-1.5 border-b border-border/40 bg-muted/20 backdrop-blur-sm">
|
||||||
{/* Left: provider label */}
|
{/* Left: provider label */}
|
||||||
|
|
@ -122,20 +109,7 @@ export default function ModelSelectorBar({
|
||||||
<span className="capitalize">{provider}</span>
|
<span className="capitalize">{provider}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: model picker + refresh */}
|
{/* Right: model picker */}
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{provider === 'claude' && onRefreshModels && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
title="Refresh model list from 9router"
|
|
||||||
className="rounded-md border border-border/50 bg-background/60 p-1 text-muted-foreground hover:bg-accent hover:text-foreground hover:border-border transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -190,6 +164,5 @@ export default function ModelSelectorBar({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue