From f05bf9fac4e9102058137e9a3d4dc2fccff2a7f5 Mon Sep 17 00:00:00 2001 From: Cesar Augusto Fonseca Date: Sat, 21 Feb 2026 13:15:47 -0300 Subject: [PATCH 01/16] feat: color badges for subagent types with .claude/agents/ config support Subagent badges now show distinct colors instead of generic gray. Colors are resolved from the project's .claude/agents/*.md frontmatter (color field), with deterministic hash-based fallback for unconfigured types. New AgentConfigReader service reads agent definitions via IPC, cached per project root to avoid redundant disk reads on session refreshes. Team member colors remain unaffected (team branch has priority). --- src/main/http/utility.ts | 13 +- src/main/ipc/utility.ts | 22 +++- .../services/parsing/AgentConfigReader.ts | 75 +++++++++++ src/main/services/parsing/index.ts | 1 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 8 ++ .../components/chat/items/SubagentItem.tsx | 24 ++-- src/renderer/constants/teamColors.ts | 24 ++++ .../store/slices/sessionDetailSlice.ts | 19 +++ src/shared/types/api.ts | 12 ++ .../parsing/AgentConfigReader.test.ts | 95 ++++++++++++++ test/renderer/constants/teamColors.test.ts | 116 ++++++++++++++++++ 12 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 src/main/services/parsing/AgentConfigReader.ts create mode 100644 test/main/services/parsing/AgentConfigReader.test.ts create mode 100644 test/renderer/constants/teamColors.test.ts diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index 2c7f4504..ae86bb90 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -14,7 +14,7 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; import { validateFilePath } from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; @@ -123,4 +123,15 @@ export function registerUtilityRoutes(app: FastifyInstance): void { app.post<{ Body: { url: string } }>('/api/open-external', async () => { return { success: false, error: 'Not available in browser mode' }; }); + + // Read agent configs + app.post<{ Body: { projectRoot: string } }>('/api/read-agent-configs', async (request) => { + try { + const { projectRoot } = request.body; + return await readAgentConfigs(projectRoot); + } catch (error) { + logger.error('Error in POST /api/read-agent-configs:', error); + return {}; + } + }); } diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index c6e69a6f..83d1b6db 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -12,7 +12,9 @@ import { createLogger } from '@shared/utils/logger'; import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; import * as fs from 'fs'; -import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; + +import type { AgentConfig } from '@shared/types/api'; const logger = createLogger('IPC:utility'); import { validateFilePath, validateOpenPath } from '../utils/pathValidation'; @@ -28,6 +30,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void { ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles); ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd); ipcMain.handle('read-mentioned-file', handleReadMentionedFile); + ipcMain.handle('read-agent-configs', handleReadAgentConfigs); logger.info('Utility handlers registered'); } @@ -42,6 +45,7 @@ export function removeUtilityHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('read-claude-md-files'); ipcMain.removeHandler('read-directory-claude-md'); ipcMain.removeHandler('read-mentioned-file'); + ipcMain.removeHandler('read-agent-configs'); logger.info('Utility handlers removed'); } @@ -228,3 +232,19 @@ async function handleReadMentionedFile( return null; } } + +/** + * Handler for 'read-agent-configs' IPC call. + * Reads agent definitions from project's .claude/agents/ directory. + */ +async function handleReadAgentConfigs( + _event: IpcMainInvokeEvent, + projectRoot: string +): Promise> { + try { + return await readAgentConfigs(projectRoot); + } catch (error) { + logger.error('Error in read-agent-configs:', error); + return {}; + } +} diff --git a/src/main/services/parsing/AgentConfigReader.ts b/src/main/services/parsing/AgentConfigReader.ts new file mode 100644 index 00000000..a6e23ea5 --- /dev/null +++ b/src/main/services/parsing/AgentConfigReader.ts @@ -0,0 +1,75 @@ +/** + * Agent Config Reader + * + * Reads `.claude/agents/*.md` files from a project directory and extracts + * frontmatter metadata (name, color) for use in subagent visualization. + */ + +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { AgentConfig } from '@shared/types/api'; + +const logger = createLogger('AgentConfigReader'); + +/** + * Parse simple YAML frontmatter from markdown content. + * Only extracts top-level scalar key: value pairs between --- delimiters. + */ +function parseFrontmatter(content: string): Record { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content); + if (!match) return {}; + + const result: Record = {}; + for (const line of match[1].split('\n')) { + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + // Strip surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (key) result[key] = value; + } + return result; +} + +/** + * Read agent config files from a project's `.claude/agents/` directory. + * Returns a map of agent name → config (with optional color). + */ +export async function readAgentConfigs( + projectRoot: string +): Promise> { + const agentsDir = path.join(projectRoot, '.claude', 'agents'); + const result: Record = {}; + + try { + const entries = await fs.promises.readdir(agentsDir); + const mdFiles = entries.filter((f) => f.endsWith('.md')); + + await Promise.all( + mdFiles.map(async (filename) => { + try { + const content = await fs.promises.readFile(path.join(agentsDir, filename), 'utf8'); + const frontmatter = parseFrontmatter(content); + const name = frontmatter.name || filename.replace(/\.md$/, ''); + const config: AgentConfig = { name }; + if (frontmatter.color) { + config.color = frontmatter.color; + } + result[name] = config; + } catch { + // Skip unreadable files + } + }) + ); + } catch { + // Directory doesn't exist or unreadable — normal for projects without custom agents + logger.debug(`No agents directory at ${agentsDir}`); + } + + return result; +} diff --git a/src/main/services/parsing/index.ts b/src/main/services/parsing/index.ts index 998fcdf8..98610610 100644 --- a/src/main/services/parsing/index.ts +++ b/src/main/services/parsing/index.ts @@ -8,6 +8,7 @@ * - GitIdentityResolver: Resolves git identities from sessions */ +export * from './AgentConfigReader'; export * from './ClaudeMdReader'; export * from './GitIdentityResolver'; export * from './MessageClassifier'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 88882804..09f16c5c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -169,6 +169,10 @@ const electronAPI: ElectronAPI = { readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) => ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens), + // Agent config reading + readAgentConfigs: (projectRoot: string) => + ipcRenderer.invoke('read-agent-configs', projectRoot), + // Notifications API notifications: { get: (options?: { limit?: number; offset?: number }) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 50672309..0b2d851f 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -41,6 +41,7 @@ import type { WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; +import type { AgentConfig } from '@shared/types/api'; export class HttpAPIClient implements ElectronAPI { private baseUrl: string; @@ -309,6 +310,13 @@ export class HttpAPIClient implements ElectronAPI { maxTokens, }); + // --------------------------------------------------------------------------- + // Agent config reading + // --------------------------------------------------------------------------- + + readAgentConfigs = (projectRoot: string): Promise> => + this.post>('/api/read-agent-configs', { projectRoot }); + // --------------------------------------------------------------------------- // Notifications (nested API) // --------------------------------------------------------------------------- diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index bbd8cb82..7f653642 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -11,11 +11,8 @@ import { CARD_TEXT_LIGHTER, COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, - TAG_BG, - TAG_BORDER, - TAG_TEXT, } from '@renderer/constants/cssVariables'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; @@ -78,8 +75,13 @@ export const SubagentItem: React.FC = ({ const subagentType = subagent.subagentType ?? 'Task'; const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; + // Agent configs from .claude/agents/ for color lookup + const agentConfigs = useStore((s) => s.agentConfigs); + // Team member colors (when this subagent is a team member) const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + // Type-based colors for non-team subagents (from agent config or deterministic hash) + const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; // Detect shutdown-only team activations (trivial: just a shutdown_response) const isShutdownOnly = useMemo(() => { @@ -285,11 +287,11 @@ export const SubagentItem: React.FC = ({ style={{ color: CARD_ICON_MUTED }} /> - {/* Icon - colored dot for team members, Bot icon for regular subagents */} - {teamColors ? ( + {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} + {teamColors || typeColors ? ( ) : ( = ({ /> )} - {/* Type badge - team member name or generic type */} + {/* Type badge - team member name or typed subagent */} {teamColors && subagent.team ? ( = ({ {subagentType} diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index 96c4177c..e2ce3627 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -31,6 +31,30 @@ const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue; * Get a TeamColorSet from a color name or hex string. * Falls back to blue if unrecognized. */ +const COLOR_NAMES = Object.keys(TEAMMATE_COLORS); + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +export function getSubagentTypeColorSet( + subagentType: string, + agentConfigs?: Record +): TeamColorSet { + // Use color from agent config if available + const configColor = agentConfigs?.[subagentType]?.color; + if (configColor) { + return getTeamColorSet(configColor); + } + // Fallback: deterministic hash-based color + const index = hashString(subagentType) % COLOR_NAMES.length; + return TEAMMATE_COLORS[COLOR_NAMES[index]]; +} + export function getTeamColorSet(colorName: string): TeamColorSet { if (!colorName) return DEFAULT_COLOR; diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 344e93c3..7eb2dd8c 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -25,6 +25,7 @@ const sessionRefreshGeneration = new Map(); const sessionRefreshInFlight = new Set(); const sessionRefreshQueued = new Set(); let sessionDetailFetchGeneration = 0; +let agentConfigsCachedForProject = ''; import { getAllTabs } from '../utils/paneHelpers'; @@ -37,6 +38,7 @@ import type { } from '@renderer/types/contextInjection'; import type { ClaudeMdFileInfo, SessionDetail } from '@renderer/types/data'; import type { AIGroup, SessionConversation } from '@renderer/types/groups'; +import type { AgentConfig } from '@shared/types/api'; import type { StateCreator } from 'zustand'; // ============================================================================= @@ -92,6 +94,9 @@ export interface SessionDetailSlice { // Context phase info (compaction boundaries) sessionPhaseInfo: ContextPhaseInfo | null; + // Agent configs from .claude/agents/ (keyed by agent name) + agentConfigs: Record; + // Visible AI Group visibleAIGroupId: string | null; selectedAIGroup: AIGroup | null; @@ -133,6 +138,8 @@ export const createSessionDetailSlice: StateCreator | null = null; let contextStats: Map | null = null; let phaseInfo: ContextPhaseInfo | null = null; + // Fetch agent configs from .claude/agents/ (only when project changes) + if (connectionMode !== 'ssh' && projectRoot && projectRoot !== agentConfigsCachedForProject) { + try { + const configs = await api.readAgentConfigs(projectRoot); + if (requestGeneration !== sessionDetailFetchGeneration) return; + agentConfigsCachedForProject = projectRoot; + set({ agentConfigs: configs }); + } catch (err) { + logger.error('Failed to read agent configs:', err); + } + } + if (connectionMode !== 'ssh' && conversation?.items) { // Fetch real CLAUDE.md token data let claudeMdTokenData: Record = {}; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9c8847ba..c81dbd8b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -29,6 +29,15 @@ import type { SubagentDetail, } from '@main/types'; +// ============================================================================= +// Agent Config +// ============================================================================= + +export interface AgentConfig { + name: string; + color?: string; +} + // ============================================================================= // Notifications API // ============================================================================= @@ -366,6 +375,9 @@ export interface ElectronAPI { maxTokens?: number ) => Promise; + // Agent config reading + readAgentConfigs: (projectRoot: string) => Promise>; + // Notifications API notifications: NotificationsAPI; diff --git a/test/main/services/parsing/AgentConfigReader.test.ts b/test/main/services/parsing/AgentConfigReader.test.ts new file mode 100644 index 00000000..114480d4 --- /dev/null +++ b/test/main/services/parsing/AgentConfigReader.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +import { readAgentConfigs } from '@main/services/parsing/AgentConfigReader'; + +describe('readAgentConfigs', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeAgent(filename: string, content: string): void { + const agentsDir = path.join(tmpDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, filename), content); + } + + it('returns empty object when .claude/agents/ does not exist', async () => { + const result = await readAgentConfigs(tmpDir); + expect(result).toEqual({}); + }); + + it('parses agent with name and color from frontmatter', async () => { + writeAgent('test-agent.md', `--- +name: test-agent +color: red +--- +# Test agent +`); + const result = await readAgentConfigs(tmpDir); + expect(result).toEqual({ + 'test-agent': { name: 'test-agent', color: 'red' }, + }); + }); + + it('uses filename as name when frontmatter has no name field', async () => { + writeAgent('my-agent.md', `--- +color: blue +--- +# My Agent +`); + const result = await readAgentConfigs(tmpDir); + expect(result['my-agent']).toBeDefined(); + expect(result['my-agent'].color).toBe('blue'); + }); + + it('handles agents without color field', async () => { + writeAgent('plain.md', `--- +name: plain +description: "A plain agent" +--- +Content +`); + const result = await readAgentConfigs(tmpDir); + expect(result.plain).toEqual({ name: 'plain' }); + expect(result.plain.color).toBeUndefined(); + }); + + it('handles agents without frontmatter', async () => { + writeAgent('no-front.md', '# Just markdown\nNo frontmatter here.'); + const result = await readAgentConfigs(tmpDir); + expect(result['no-front']).toEqual({ name: 'no-front' }); + }); + + it('reads multiple agents', async () => { + writeAgent('a.md', `---\nname: a\ncolor: green\n---\n`); + writeAgent('b.md', `---\nname: b\ncolor: purple\n---\n`); + const result = await readAgentConfigs(tmpDir); + expect(Object.keys(result)).toHaveLength(2); + expect(result.a.color).toBe('green'); + expect(result.b.color).toBe('purple'); + }); + + it('strips quotes from frontmatter values', async () => { + writeAgent('quoted.md', `---\nname: "quoted-agent"\ncolor: 'cyan'\n---\n`); + const result = await readAgentConfigs(tmpDir); + expect(result['quoted-agent']).toEqual({ name: 'quoted-agent', color: 'cyan' }); + }); + + it('ignores non-md files', async () => { + const agentsDir = path.join(tmpDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'readme.txt'), 'not an agent'); + writeAgent('real.md', `---\nname: real\ncolor: red\n---\n`); + const result = await readAgentConfigs(tmpDir); + expect(Object.keys(result)).toEqual(['real']); + }); +}); diff --git a/test/renderer/constants/teamColors.test.ts b/test/renderer/constants/teamColors.test.ts new file mode 100644 index 00000000..00844991 --- /dev/null +++ b/test/renderer/constants/teamColors.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { getSubagentTypeColorSet, getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors'; + +function isValidColorSet(cs: TeamColorSet): boolean { + return typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string'; +} + +// ============================================================================= +// getTeamColorSet +// ============================================================================= + +describe('getTeamColorSet', () => { + it('returns blue (default) for empty string', () => { + const result = getTeamColorSet(''); + expect(result.border).toBe('#3b82f6'); + }); + + it('resolves named colors', () => { + expect(getTeamColorSet('green').border).toBe('#22c55e'); + expect(getTeamColorSet('red').border).toBe('#ef4444'); + expect(getTeamColorSet('purple').border).toBe('#a855f7'); + }); + + it('is case-insensitive for named colors', () => { + expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green')); + expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue')); + }); + + it('generates a color set from hex strings', () => { + const result = getTeamColorSet('#ff5500'); + expect(result.border).toBe('#ff5500'); + expect(result.badge).toBe('#ff550026'); + expect(result.text).toBe('#ff5500'); + }); + + it('falls back to blue for unknown non-hex strings', () => { + const result = getTeamColorSet('nonexistent'); + expect(result.border).toBe('#3b82f6'); + }); +}); + +// ============================================================================= +// getSubagentTypeColorSet +// ============================================================================= + +describe('getSubagentTypeColorSet', () => { + it('always returns a valid TeamColorSet without agent configs', () => { + const types = ['test-agent', 'quality-fixer', 'Explore', 'Plan', 'my-custom-agent', 'anything']; + for (const t of types) { + const result = getSubagentTypeColorSet(t); + expect(isValidColorSet(result)).toBe(true); + } + }); + + it('is deterministic — same input always returns same color', () => { + const a = getSubagentTypeColorSet('my-custom-agent'); + const b = getSubagentTypeColorSet('my-custom-agent'); + expect(a).toEqual(b); + }); + + it('different types can produce different colors', () => { + const results = new Set( + ['Explore', 'Plan', 'test-agent', 'quality-fixer', 'claude-md-auditor', 'Bash', 'general-purpose', 'statusline-setup'] + .map((t) => getSubagentTypeColorSet(t).border) + ); + expect(results.size).toBeGreaterThan(1); + }); + + it('uses color from agent config when available', () => { + const configs = { + 'test-agent': { name: 'test-agent', color: 'red' }, + }; + const result = getSubagentTypeColorSet('test-agent', configs); + // Should use the named "red" color from getTeamColorSet + expect(result.border).toBe('#ef4444'); + expect(result.text).toBe('#f87171'); + }); + + it('uses hex color from agent config', () => { + const configs = { + 'my-agent': { name: 'my-agent', color: '#ff00ff' }, + }; + const result = getSubagentTypeColorSet('my-agent', configs); + expect(result.border).toBe('#ff00ff'); + }); + + it('falls back to hash when agent config has no color', () => { + const configs = { + 'my-agent': { name: 'my-agent' }, + }; + const withConfig = getSubagentTypeColorSet('my-agent', configs); + const withoutConfig = getSubagentTypeColorSet('my-agent'); + // Should be the same — both use hash fallback + expect(withConfig).toEqual(withoutConfig); + }); + + it('falls back to hash when agent type not in configs', () => { + const configs = { + 'other-agent': { name: 'other-agent', color: 'green' }, + }; + const withConfig = getSubagentTypeColorSet('unknown-agent', configs); + const withoutConfig = getSubagentTypeColorSet('unknown-agent'); + expect(withConfig).toEqual(withoutConfig); + }); + + it('does not interfere with getTeamColorSet', () => { + const teamGreen = getTeamColorSet('green'); + expect(teamGreen.border).toBe('#22c55e'); + + const configs = { green: { name: 'green', color: 'purple' } }; + getSubagentTypeColorSet('green', configs); + // Team API remains unaffected + expect(getTeamColorSet('green').border).toBe('#22c55e'); + }); +}); From 75dfcf2d502bbbee8e3773fd4e9bb8583dd1439a Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 22 Feb 2026 02:03:22 +0900 Subject: [PATCH 02/16] feat: implement SearchTextCache and SearchTextExtractor for efficient text extraction and caching - Added SearchTextCache for LRU caching of extracted search text with mtime invalidation. - Introduced SearchTextExtractor for lightweight extraction of searchable text from session messages. - Updated SessionSearcher to utilize the new extractor and cache for improved search performance. - Added tests for SearchTextCache and SearchTextExtractor to ensure functionality and correctness. --- .../services/discovery/SearchTextCache.ts | 95 ++++++++ .../services/discovery/SearchTextExtractor.ts | 159 ++++++++++++ .../services/discovery/SessionSearcher.ts | 142 +++-------- src/main/services/discovery/index.ts | 2 + .../discovery/SearchTextCache.test.ts | 119 +++++++++ .../discovery/SearchTextExtractor.test.ts | 230 ++++++++++++++++++ 6 files changed, 643 insertions(+), 104 deletions(-) create mode 100644 src/main/services/discovery/SearchTextCache.ts create mode 100644 src/main/services/discovery/SearchTextExtractor.ts create mode 100644 test/main/services/discovery/SearchTextCache.test.ts create mode 100644 test/main/services/discovery/SearchTextExtractor.test.ts diff --git a/src/main/services/discovery/SearchTextCache.ts b/src/main/services/discovery/SearchTextCache.ts new file mode 100644 index 00000000..68c61ba9 --- /dev/null +++ b/src/main/services/discovery/SearchTextCache.ts @@ -0,0 +1,95 @@ +/** + * SearchTextCache - LRU cache for extracted search text with mtime invalidation. + * + * Caches SearchTextResult per session file path. Entries are small (~1KB each, + * just text + metadata), so 200 entries is a reasonable default. + * + * Invalidation: mtime comparison on get(). If the file's mtime has changed + * since caching, the entry is considered stale and undefined is returned. + * No TTL needed — mtime check is sufficient. + */ + +import type { SearchableEntry } from './SearchTextExtractor'; + +interface CacheEntry { + entries: SearchableEntry[]; + sessionTitle: string | undefined; + mtimeMs: number; +} + +export class SearchTextCache { + private readonly cache = new Map(); + private readonly maxSize: number; + + constructor(maxSize: number = 200) { + this.maxSize = maxSize; + } + + /** + * Get cached entries for a file path if the mtime matches. + * Returns undefined if not cached or stale. + */ + get( + filePath: string, + mtimeMs: number + ): { entries: SearchableEntry[]; sessionTitle: string | undefined } | undefined { + const entry = this.cache.get(filePath); + if (!entry) return undefined; + + // Stale — file was modified since we cached it + if (entry.mtimeMs !== mtimeMs) { + this.cache.delete(filePath); + return undefined; + } + + // LRU: delete and re-insert to move to end (most recent) + this.cache.delete(filePath); + this.cache.set(filePath, entry); + + return { entries: entry.entries, sessionTitle: entry.sessionTitle }; + } + + /** + * Cache extracted entries for a file path. + */ + set( + filePath: string, + mtimeMs: number, + entries: SearchableEntry[], + sessionTitle: string | undefined + ): void { + // If already exists, delete first to update position + this.cache.delete(filePath); + + // Evict oldest if at capacity + if (this.cache.size >= this.maxSize) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) { + this.cache.delete(oldest); + } + } + + this.cache.set(filePath, { entries, sessionTitle, mtimeMs }); + } + + /** + * Remove a specific entry from the cache. + */ + invalidate(filePath: string): void { + this.cache.delete(filePath); + } + + /** + * Clear all cached entries. + */ + clear(): void { + this.cache.clear(); + } + + /** + * Current number of cached entries. + */ + get size(): number { + return this.cache.size; + } +} diff --git a/src/main/services/discovery/SearchTextExtractor.ts b/src/main/services/discovery/SearchTextExtractor.ts new file mode 100644 index 00000000..1569eb5d --- /dev/null +++ b/src/main/services/discovery/SearchTextExtractor.ts @@ -0,0 +1,159 @@ +/** + * SearchTextExtractor - Lightweight text extraction for search. + * + * Mirrors ChunkBuilder's classification loop (classifyMessages → buffer flush) + * but only extracts searchable text + metadata, skipping all expensive operations: + * - No tool execution building + * - No semantic step extraction + * - No subagent linking + * - No timeline gap filling + * - No metrics calculation + */ + +import { classifyMessages } from '@main/services/parsing/MessageClassifier'; +import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; + +import type { ParsedMessage } from '@main/types'; + +/** + * A lightweight entry containing only the data needed for search matching. + */ +export interface SearchableEntry { + text: string; + groupId: string; + messageType: 'user' | 'assistant'; + itemType: 'user' | 'ai'; + timestamp: number; + messageUuid: string; +} + +/** + * Result of extracting searchable text from a session's messages. + */ +export interface SearchTextResult { + entries: SearchableEntry[]; + sessionTitle: string | undefined; +} + +/** + * Extract searchable text entries from parsed messages. + * + * Algorithm mirrors ChunkBuilder.buildChunks() lines 78-151: + * - Filter to main thread (!m.isSidechain) + * - classifyMessages() — cheap type guard checks + * - Walk classified messages with an aiBuffer: + * - hardNoise → skip + * - compact / system / user → flush AI buffer, then handle + * - ai → push to buffer + * - Flush remaining buffer at end + */ +export function extractSearchableEntries(messages: ParsedMessage[]): SearchTextResult { + const entries: SearchableEntry[] = []; + let sessionTitle: string | undefined; + + // Filter to main thread messages (non-sidechain) — same as ChunkBuilder line 82 + const mainMessages = messages.filter((m) => !m.isSidechain); + const classified = classifyMessages(mainMessages); + + let aiBuffer: ParsedMessage[] = []; + + for (const { message, category } of classified) { + switch (category) { + case 'hardNoise': + // Skip — filtered out + break; + + case 'compact': + case 'system': + // Flush AI buffer, but compact/system messages have no searchable text + if (aiBuffer.length > 0) { + const aiEntry = extractAIEntry(aiBuffer); + if (aiEntry) entries.push(aiEntry); + aiBuffer = []; + } + break; + + case 'user': { + // Flush AI buffer + if (aiBuffer.length > 0) { + const aiEntry = extractAIEntry(aiBuffer); + if (aiEntry) entries.push(aiEntry); + aiBuffer = []; + } + // Extract user text + const userText = extractUserText(message); + if (userText) { + if (!sessionTitle) { + sessionTitle = userText.slice(0, 100); + } + entries.push({ + text: userText, + groupId: `user-${message.uuid}`, + messageType: 'user', + itemType: 'user', + timestamp: message.timestamp.getTime(), + messageUuid: message.uuid, + }); + } + break; + } + + case 'ai': + aiBuffer.push(message); + break; + } + } + + // Flush remaining AI buffer + if (aiBuffer.length > 0) { + const aiEntry = extractAIEntry(aiBuffer); + if (aiEntry) entries.push(aiEntry); + } + + return { entries, sessionTitle }; +} + +/** + * Extract the last text output from an AI message buffer. + * Scans backward for the last assistant message with a text content block. + */ +function extractAIEntry(buffer: ParsedMessage[]): SearchableEntry | null { + // Scan backward for last assistant message with text content + for (let i = buffer.length - 1; i >= 0; i--) { + const msg = buffer[i]; + if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue; + + // Find the last text block in this message + for (let j = msg.content.length - 1; j >= 0; j--) { + const block = msg.content[j]; + if (block.type === 'text' && block.text) { + return { + text: block.text, + groupId: `ai-${buffer[0].uuid}`, + messageType: 'assistant', + itemType: 'ai', + timestamp: msg.timestamp.getTime(), + messageUuid: msg.uuid, + }; + } + } + } + return null; +} + +/** + * Extract searchable text from a user message. + * Shared logic previously in SessionSearcher.extractUserSearchableText(). + */ +export function extractUserText(message: ParsedMessage): string { + let rawText = ''; + if (typeof message.content === 'string') { + rawText = message.content; + } else if (Array.isArray(message.content)) { + rawText = message.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(''); + } + return sanitizeDisplayContent(rawText); +} diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 3ebe9827..a4382c98 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -6,21 +6,14 @@ * - Search within a single session file * - Restrict matching scope to User text + AI last text output * - Extract context around each match occurrence + * + * Uses SearchTextExtractor for lightweight text extraction (skips ChunkBuilder) + * and SearchTextCache for mtime-based caching of extracted entries. */ -import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; -import { - isEnhancedAIChunk, - isUserChunk, - type ParsedMessage, - type SearchResult, - type SearchSessionsResult, - type SemanticStep, -} from '@main/types'; import { parseJsonlFile } from '@main/utils/jsonl'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; -import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { extractMarkdownPlainText, @@ -28,36 +21,31 @@ import { } from '@shared/utils/markdownTextSearch'; import * as path from 'path'; +import { SearchTextCache } from './SearchTextCache'; +import { extractSearchableEntries } from './SearchTextExtractor'; import { subprojectRegistry } from './SubprojectRegistry'; +import type { SearchableEntry } from './SearchTextExtractor'; import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; +import type { SearchResult, SearchSessionsResult } from '@main/types'; const logger = createLogger('Discovery:SessionSearcher'); const SSH_FAST_SEARCH_STAGE_LIMITS = [40, 140, 320] as const; const SSH_FAST_SEARCH_MIN_RESULTS = 8; const SSH_FAST_SEARCH_TIME_BUDGET_MS = 4500; -interface SearchableEntry { - text: string; - groupId: string; - messageType: 'user' | 'assistant'; - itemType: 'user' | 'ai'; - timestamp: number; - messageUuid: string; -} - /** * SessionSearcher provides methods for searching sessions. */ export class SessionSearcher { private readonly projectsDir: string; - private readonly chunkBuilder: ChunkBuilder; private readonly fsProvider: FileSystemProvider; + private readonly searchCache: SearchTextCache; constructor(projectsDir: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir; - this.chunkBuilder = new ChunkBuilder(); this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); + this.searchCache = new SearchTextCache(); } /** @@ -151,7 +139,8 @@ export class SessionSearcher { sessionId, file.filePath, normalizedQuery, - maxResults + maxResults, + file.mtimeMs ); }) ); @@ -207,11 +196,15 @@ export class SessionSearcher { /** * Searches a single session file for a query string. * + * Uses SearchTextExtractor for lightweight text extraction (no ChunkBuilder) + * and SearchTextCache for mtime-based caching. + * * @param projectId - The project ID * @param sessionId - The session ID * @param filePath - Path to the session file * @param query - Normalized search query (lowercase) * @param maxResults - Maximum number of results to return + * @param mtimeMs - File modification time for cache invalidation * @returns Array of search results */ async searchSessionFile( @@ -219,71 +212,35 @@ export class SessionSearcher { sessionId: string, filePath: string, query: string, - maxResults: number + maxResults: number, + mtimeMs: number ): Promise { const results: SearchResult[] = []; - let sessionTitle: string | undefined; - const messages = await parseJsonlFile(filePath, this.fsProvider); - const chunks = this.chunkBuilder.buildChunks(messages, []); - for (const chunk of chunks) { - if (results.length >= maxResults) { - break; - } + // Check cache first + let cached = this.searchCache.get(filePath, mtimeMs); + if (!cached) { + // Cache miss — parse and extract + const messages = await parseJsonlFile(filePath, this.fsProvider); + const extracted = extractSearchableEntries(messages); + this.searchCache.set(filePath, mtimeMs, extracted.entries, extracted.sessionTitle); + cached = extracted; + } - if (isUserChunk(chunk)) { - const userText = this.extractUserSearchableText(chunk.userMessage); - if (!sessionTitle && userText) { - sessionTitle = userText.slice(0, 100); - } - if (!userText) { - continue; - } - const searchableEntry: SearchableEntry = { - text: userText, - groupId: chunk.id, - messageType: 'user', - itemType: 'user', - timestamp: chunk.userMessage.timestamp.getTime(), - messageUuid: chunk.userMessage.uuid, - }; - this.collectMatchesForEntry( - searchableEntry, - query, - results, - maxResults, - projectId, - sessionId, - sessionTitle - ); - continue; - } + const { entries, sessionTitle } = cached; - if (isEnhancedAIChunk(chunk)) { - const lastOutputStep = this.findLastOutputTextStep(chunk.semanticSteps); - const outputText = lastOutputStep?.content.outputText; - if (!lastOutputStep || !outputText) { - continue; - } + for (const entry of entries) { + if (results.length >= maxResults) break; - const searchableEntry: SearchableEntry = { - text: outputText, - groupId: chunk.id, - messageType: 'assistant', - itemType: 'ai', - timestamp: lastOutputStep.startTime.getTime(), - messageUuid: lastOutputStep.sourceMessageId ?? chunk.responses[0]?.uuid ?? '', - }; - this.collectMatchesForEntry( - searchableEntry, - query, - results, - maxResults, - projectId, - sessionId, - sessionTitle - ); - } + this.collectMatchesForEntry( + entry, + query, + results, + maxResults, + projectId, + sessionId, + sessionTitle + ); } return results; @@ -342,29 +299,6 @@ export class SessionSearcher { } } - private extractUserSearchableText(message: ParsedMessage): string { - let rawText = ''; - if (typeof message.content === 'string') { - rawText = message.content; - } else if (Array.isArray(message.content)) { - rawText = message.content - .filter((block) => block.type === 'text') - .map((block) => block.text) - .join(''); - } - return sanitizeDisplayContent(rawText); - } - - private findLastOutputTextStep(steps: SemanticStep[]): SemanticStep | null { - for (let i = steps.length - 1; i >= 0; i--) { - const step = steps[i]; - if (step.type === 'output' && step.content.outputText) { - return step; - } - } - return null; - } - private async collectFulfilledInBatches( items: T[], batchSize: number, diff --git a/src/main/services/discovery/index.ts b/src/main/services/discovery/index.ts index 6c7b1897..815eaaff 100644 --- a/src/main/services/discovery/index.ts +++ b/src/main/services/discovery/index.ts @@ -12,6 +12,8 @@ export * from './ProjectPathResolver'; export * from './ProjectScanner'; +export * from './SearchTextCache'; +export * from './SearchTextExtractor'; export * from './SessionContentFilter'; export * from './SessionSearcher'; export * from './SubagentLocator'; diff --git a/test/main/services/discovery/SearchTextCache.test.ts b/test/main/services/discovery/SearchTextCache.test.ts new file mode 100644 index 00000000..12c510d0 --- /dev/null +++ b/test/main/services/discovery/SearchTextCache.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { SearchTextCache } from '../../../../src/main/services/discovery/SearchTextCache'; + +import type { SearchableEntry } from '../../../../src/main/services/discovery/SearchTextExtractor'; + +function makeEntry(text: string, groupId: string): SearchableEntry { + return { + text, + groupId, + messageType: 'user', + itemType: 'user', + timestamp: Date.now(), + messageUuid: groupId, + }; +} + +describe('SearchTextCache', () => { + it('returns cached entry on mtime match', () => { + const cache = new SearchTextCache(); + const entries = [makeEntry('hello', 'user-1')]; + cache.set('/path/a.jsonl', 1000, entries, 'Title A'); + + const result = cache.get('/path/a.jsonl', 1000); + expect(result).toBeDefined(); + expect(result!.entries).toEqual(entries); + expect(result!.sessionTitle).toBe('Title A'); + }); + + it('returns undefined on mtime mismatch (stale)', () => { + const cache = new SearchTextCache(); + const entries = [makeEntry('hello', 'user-1')]; + cache.set('/path/a.jsonl', 1000, entries, 'Title A'); + + const result = cache.get('/path/a.jsonl', 2000); + expect(result).toBeUndefined(); + }); + + it('returns undefined for uncached paths', () => { + const cache = new SearchTextCache(); + const result = cache.get('/path/missing.jsonl', 1000); + expect(result).toBeUndefined(); + }); + + it('evicts oldest entry when at max capacity', () => { + const cache = new SearchTextCache(3); + + cache.set('/path/1.jsonl', 100, [makeEntry('one', 'u1')], 'One'); + cache.set('/path/2.jsonl', 200, [makeEntry('two', 'u2')], 'Two'); + cache.set('/path/3.jsonl', 300, [makeEntry('three', 'u3')], 'Three'); + + expect(cache.size).toBe(3); + + // Adding a 4th entry should evict the oldest (1.jsonl) + cache.set('/path/4.jsonl', 400, [makeEntry('four', 'u4')], 'Four'); + + expect(cache.size).toBe(3); + expect(cache.get('/path/1.jsonl', 100)).toBeUndefined(); + expect(cache.get('/path/4.jsonl', 400)).toBeDefined(); + }); + + it('LRU access moves entry to end, preserving it from eviction', () => { + const cache = new SearchTextCache(3); + + cache.set('/path/1.jsonl', 100, [makeEntry('one', 'u1')], 'One'); + cache.set('/path/2.jsonl', 200, [makeEntry('two', 'u2')], 'Two'); + cache.set('/path/3.jsonl', 300, [makeEntry('three', 'u3')], 'Three'); + + // Access entry 1, moving it to end + cache.get('/path/1.jsonl', 100); + + // Adding a 4th should now evict entry 2 (oldest after LRU access) + cache.set('/path/4.jsonl', 400, [makeEntry('four', 'u4')], 'Four'); + + expect(cache.get('/path/1.jsonl', 100)).toBeDefined(); + expect(cache.get('/path/2.jsonl', 200)).toBeUndefined(); + }); + + it('invalidate() removes a specific entry', () => { + const cache = new SearchTextCache(); + cache.set('/path/a.jsonl', 1000, [makeEntry('hello', 'u1')], 'Title'); + + cache.invalidate('/path/a.jsonl'); + expect(cache.get('/path/a.jsonl', 1000)).toBeUndefined(); + expect(cache.size).toBe(0); + }); + + it('clear() empties the cache', () => { + const cache = new SearchTextCache(); + cache.set('/path/1.jsonl', 100, [makeEntry('one', 'u1')], 'One'); + cache.set('/path/2.jsonl', 200, [makeEntry('two', 'u2')], 'Two'); + + expect(cache.size).toBe(2); + cache.clear(); + expect(cache.size).toBe(0); + }); + + it('handles undefined sessionTitle', () => { + const cache = new SearchTextCache(); + cache.set('/path/a.jsonl', 1000, [], undefined); + + const result = cache.get('/path/a.jsonl', 1000); + expect(result).toBeDefined(); + expect(result!.sessionTitle).toBeUndefined(); + expect(result!.entries).toEqual([]); + }); + + it('updates existing entry on re-set', () => { + const cache = new SearchTextCache(); + cache.set('/path/a.jsonl', 1000, [makeEntry('old', 'u1')], 'Old'); + cache.set('/path/a.jsonl', 2000, [makeEntry('new', 'u2')], 'New'); + + const result = cache.get('/path/a.jsonl', 2000); + expect(result).toBeDefined(); + expect(result!.entries[0].text).toBe('new'); + expect(result!.sessionTitle).toBe('New'); + expect(cache.size).toBe(1); + }); +}); diff --git a/test/main/services/discovery/SearchTextExtractor.test.ts b/test/main/services/discovery/SearchTextExtractor.test.ts new file mode 100644 index 00000000..1d2b122c --- /dev/null +++ b/test/main/services/discovery/SearchTextExtractor.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractSearchableEntries, + extractUserText, +} from '../../../../src/main/services/discovery/SearchTextExtractor'; + +import type { ParsedMessage } from '../../../../src/main/types'; + +function makeUserMessage( + uuid: string, + content: string, + timestamp = '2026-01-01T00:00:00.000Z' +): ParsedMessage { + return { + uuid, + type: 'user', + role: 'user', + content, + timestamp: new Date(timestamp), + isMeta: false, + isSidechain: false, + } as ParsedMessage; +} + +function makeAssistantMessage( + uuid: string, + textContent: string, + timestamp = '2026-01-01T00:00:01.000Z' +): ParsedMessage { + return { + uuid, + type: 'assistant', + role: 'assistant', + content: [{ type: 'text', text: textContent }], + timestamp: new Date(timestamp), + isMeta: false, + isSidechain: false, + } as ParsedMessage; +} + +function makeAssistantWithThinking( + uuid: string, + thinking: string, + textContent: string, + timestamp = '2026-01-01T00:00:01.000Z' +): ParsedMessage { + return { + uuid, + type: 'assistant', + role: 'assistant', + content: [ + { type: 'thinking', thinking }, + { type: 'text', text: textContent }, + ], + timestamp: new Date(timestamp), + isMeta: false, + isSidechain: false, + } as ParsedMessage; +} + +function makeToolResultMessage( + uuid: string, + timestamp = '2026-01-01T00:00:01.500Z' +): ParsedMessage { + return { + uuid, + type: 'user', + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'result text' }], + timestamp: new Date(timestamp), + isMeta: true, + isSidechain: false, + } as ParsedMessage; +} + +describe('SearchTextExtractor', () => { + describe('extractSearchableEntries', () => { + it('produces user-{uuid} groupIds for user messages', () => { + const messages = [makeUserMessage('u1', 'hello world')]; + const result = extractSearchableEntries(messages); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].groupId).toBe('user-u1'); + expect(result.entries[0].itemType).toBe('user'); + expect(result.entries[0].messageType).toBe('user'); + expect(result.entries[0].text).toBe('hello world'); + }); + + it('produces ai-{uuid} groupIds for AI groups (using first buffer message uuid)', () => { + const messages = [ + makeUserMessage('u1', 'question'), + makeToolResultMessage('tr1', '2026-01-01T00:00:01.000Z'), + makeAssistantMessage('a1', 'thinking...', '2026-01-01T00:00:02.000Z'), + makeAssistantMessage('a2', 'final answer', '2026-01-01T00:00:03.000Z'), + ]; + const result = extractSearchableEntries(messages); + + const aiEntries = result.entries.filter((e) => e.itemType === 'ai'); + expect(aiEntries).toHaveLength(1); + // groupId uses the first message in the AI buffer + expect(aiEntries[0].groupId).toMatch(/^ai-/); + // Text is from the last assistant message with text + expect(aiEntries[0].text).toBe('final answer'); + }); + + it('extracts last AI text output correctly (backward scan)', () => { + const messages = [ + makeUserMessage('u1', 'question'), + makeAssistantMessage('a1', 'older output', '2026-01-01T00:00:01.000Z'), + makeAssistantMessage('a2', 'latest output', '2026-01-01T00:00:02.000Z'), + ]; + const result = extractSearchableEntries(messages); + + const aiEntries = result.entries.filter((e) => e.itemType === 'ai'); + expect(aiEntries).toHaveLength(1); + expect(aiEntries[0].text).toBe('latest output'); + }); + + it('handles assistant messages with thinking + text blocks', () => { + const messages = [ + makeUserMessage('u1', 'question'), + makeAssistantWithThinking('a1', 'internal reasoning', 'visible answer'), + ]; + const result = extractSearchableEntries(messages); + + const aiEntries = result.entries.filter((e) => e.itemType === 'ai'); + expect(aiEntries).toHaveLength(1); + expect(aiEntries[0].text).toBe('visible answer'); + }); + + it('skips sidechain messages', () => { + const sidechain: ParsedMessage = { + ...makeUserMessage('u-side', 'sidechain text'), + isSidechain: true, + } as ParsedMessage; + const messages = [sidechain, makeUserMessage('u1', 'main thread')]; + const result = extractSearchableEntries(messages); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].text).toBe('main thread'); + }); + + it('extracts sessionTitle from first user message (truncated to 100 chars)', () => { + const longText = 'a'.repeat(200); + const messages = [ + makeUserMessage('u1', longText), + makeUserMessage('u2', 'second message'), + ]; + const result = extractSearchableEntries(messages); + + expect(result.sessionTitle).toBe('a'.repeat(100)); + }); + + it('handles empty messages array', () => { + const result = extractSearchableEntries([]); + expect(result.entries).toHaveLength(0); + expect(result.sessionTitle).toBeUndefined(); + }); + + it('handles messages with no user messages', () => { + const messages = [ + makeAssistantMessage('a1', 'just AI talking'), + ]; + const result = extractSearchableEntries(messages); + + expect(result.sessionTitle).toBeUndefined(); + const aiEntries = result.entries.filter((e) => e.itemType === 'ai'); + expect(aiEntries).toHaveLength(1); + }); + + it('handles AI buffer with no text content', () => { + const noTextAssistant: ParsedMessage = { + uuid: 'a1', + type: 'assistant', + role: 'assistant', + content: [{ type: 'thinking', thinking: 'just thinking' }], + timestamp: new Date('2026-01-01T00:00:01.000Z'), + isMeta: false, + isSidechain: false, + } as ParsedMessage; + const messages = [makeUserMessage('u1', 'question'), noTextAssistant]; + const result = extractSearchableEntries(messages); + + const aiEntries = result.entries.filter((e) => e.itemType === 'ai'); + expect(aiEntries).toHaveLength(0); + }); + + it('flushes AI buffer on user messages', () => { + const messages = [ + makeUserMessage('u1', 'first question'), + makeAssistantMessage('a1', 'first answer', '2026-01-01T00:00:01.000Z'), + makeUserMessage('u2', 'second question', '2026-01-01T00:00:02.000Z'), + makeAssistantMessage('a2', 'second answer', '2026-01-01T00:00:03.000Z'), + ]; + const result = extractSearchableEntries(messages); + + expect(result.entries).toHaveLength(4); + const userEntries = result.entries.filter((e) => e.itemType === 'user'); + const aiEntries = result.entries.filter((e) => e.itemType === 'ai'); + expect(userEntries).toHaveLength(2); + expect(aiEntries).toHaveLength(2); + expect(aiEntries[0].text).toBe('first answer'); + expect(aiEntries[1].text).toBe('second answer'); + }); + }); + + describe('extractUserText', () => { + it('extracts string content', () => { + const msg = makeUserMessage('u1', 'hello world'); + expect(extractUserText(msg)).toBe('hello world'); + }); + + it('extracts array content with text blocks', () => { + const msg: ParsedMessage = { + uuid: 'u1', + type: 'user', + role: 'user', + content: [ + { type: 'text', text: 'part one' }, + { type: 'text', text: ' part two' }, + ], + timestamp: new Date(), + isMeta: false, + isSidechain: false, + } as ParsedMessage; + expect(extractUserText(msg)).toBe('part one part two'); + }); + }); +}); From 9e7d3b58a145663a93cb374c039aaf9f41df5e5c Mon Sep 17 00:00:00 2001 From: proxy Date: Sat, 21 Feb 2026 15:46:25 -0500 Subject: [PATCH 03/16] fix: resolve perf regression in transcript loading and session search Two performance regressions introduced in recent PRs: 1. readAgentConfigs blocked transcript rendering (PR #50) The agent config IPC call was awaited on the critical path of fetchSessionDetail, preventing any transcript data from rendering until the filesystem read completed. On macOS this was especially noticeable due to security checks on first directory access. Fixed by making the call fire-and-forget: the transcript renders immediately and subagent color badges update asynchronously. Also set the project cache key optimistically before the async call to prevent duplicate in-flight requests on rapid navigation. 2. SessionSearcher stat()-called every session file on each search (PR #53) LocalFileSystemProvider.readdir() did not populate the optional mtimeMs field on FsDirent entries. The new SearchTextCache-based SessionSearcher fell back to an individual fsProvider.stat() call per session file when mtimeMs was missing, adding N extra filesystem round-trips on every search in local mode. Fixed by statting all entries concurrently inside readdir(), so mtimeMs is always populated and the stat fallback is never triggered. Co-Authored-By: Claude Sonnet 4.6 --- .../infrastructure/LocalFileSystemProvider.ts | 24 +++++++++++++++---- .../store/slices/sessionDetailSlice.ts | 21 +++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/main/services/infrastructure/LocalFileSystemProvider.ts b/src/main/services/infrastructure/LocalFileSystemProvider.ts index 9d7ee499..ceb5a3fb 100644 --- a/src/main/services/infrastructure/LocalFileSystemProvider.ts +++ b/src/main/services/infrastructure/LocalFileSystemProvider.ts @@ -43,11 +43,25 @@ export class LocalFileSystemProvider implements FileSystemProvider { async readdir(dirPath: string): Promise { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - return entries.map((entry) => ({ - name: entry.name, - isFile: () => entry.isFile(), - isDirectory: () => entry.isDirectory(), - })); + // Stat all entries concurrently to populate mtimeMs, used by SessionSearcher's + // mtime-based cache invalidation. Failures are silently ignored (mtimeMs stays undefined). + return Promise.all( + entries.map(async (entry) => { + let mtimeMs: number | undefined; + try { + const stat = await fs.promises.stat(`${dirPath}/${entry.name}`); + mtimeMs = stat.mtimeMs; + } catch { + // ignore + } + return { + name: entry.name, + mtimeMs, + isFile: () => entry.isFile(), + isDirectory: () => entry.isDirectory(), + }; + }) + ); } createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream { diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 7eb2dd8c..9a158f49 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -197,16 +197,19 @@ export const createSessionDetailSlice: StateCreator | null = null; let contextStats: Map | null = null; let phaseInfo: ContextPhaseInfo | null = null; - // Fetch agent configs from .claude/agents/ (only when project changes) + // Fetch agent configs from .claude/agents/ (only when project changes). + // Fire-and-forget: don't block transcript rendering — color badges update async. if (connectionMode !== 'ssh' && projectRoot && projectRoot !== agentConfigsCachedForProject) { - try { - const configs = await api.readAgentConfigs(projectRoot); - if (requestGeneration !== sessionDetailFetchGeneration) return; - agentConfigsCachedForProject = projectRoot; - set({ agentConfigs: configs }); - } catch (err) { - logger.error('Failed to read agent configs:', err); - } + agentConfigsCachedForProject = projectRoot; // Optimistic set to prevent duplicate fetches + api + .readAgentConfigs(projectRoot) + .then((configs) => { + set({ agentConfigs: configs }); + }) + .catch((err) => { + logger.error('Failed to read agent configs:', err); + agentConfigsCachedForProject = ''; // Reset so it retries next time + }); } if (connectionMode !== 'ssh' && conversation?.items) { From c69d2414d65276887c562a4091a072716c507e81 Mon Sep 17 00:00:00 2001 From: proxy Date: Sat, 21 Feb 2026 15:56:40 -0500 Subject: [PATCH 04/16] fix: auto-expand sidebar when a project is selected When the sidebar is collapsed and the user clicks a project, the session list was silently updated behind the hidden panel with no visual feedback, leaving the user confused about why nothing appeared to change. Fixed by setting sidebarCollapsed: false in both selectProject and selectRepository at the moment of selection. Both entry points are covered: - selectRepository: used by the dashboard project cards - selectProject: used by the command palette and keyboard shortcuts Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/store/slices/projectSlice.ts | 1 + src/renderer/store/slices/repositorySlice.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/renderer/store/slices/projectSlice.ts b/src/renderer/store/slices/projectSlice.ts index 7f1293af..bbcdf440 100644 --- a/src/renderer/store/slices/projectSlice.ts +++ b/src/renderer/store/slices/projectSlice.ts @@ -59,6 +59,7 @@ export const createProjectSlice: StateCreator = selectProject: (id: string) => { set({ selectedProjectId: id, + sidebarCollapsed: false, // Ensure session list is visible when a project is selected ...getSessionResetState(), }); diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts index 8313a8ea..f9b1b4da 100644 --- a/src/renderer/store/slices/repositorySlice.ts +++ b/src/renderer/store/slices/repositorySlice.ts @@ -87,6 +87,7 @@ export const createRepositorySlice: StateCreator Date: Sat, 21 Feb 2026 16:01:14 -0500 Subject: [PATCH 05/16] fix: wrap HTTP_SERVER_GET_STATUS response in IpcResult envelope The HTTP_SERVER_GET_STATUS handler returned a raw { running, port } object while all other HTTP server handlers (start, stop) return { success, data }. invokeIpcWithResult() in the preload expects the IpcResult envelope and treats a missing success field as failure, throwing 'Unknown error'. This caused an unhandled promise rejection every time the Settings page opened, because GeneralSection calls api.httpServer.getStatus() on mount. Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 6484e149..8ae60eb4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -310,7 +310,7 @@ function initializeServices(): void { }); ipcMain.handle(HTTP_SERVER_GET_STATUS, () => { - return { running: httpServer.isRunning(), port: httpServer.getPort() }; + return { success: true, data: { running: httpServer.isRunning(), port: httpServer.getPort() } }; }); // Forward SSH state changes to renderer and HTTP SSE clients From 3288831c7da7b2e0d9c5933da1f0fe33496649d5 Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 22 Feb 2026 14:31:49 +0900 Subject: [PATCH 06/16] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 32 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..5d3171a1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..a2956b5d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEAT]" +labels: feature request +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 2111272de805cc43e07df5536f4521150b84a62c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:41:40 +0000 Subject: [PATCH 07/16] chore(deps): bump the npm_and_yarn group across 1 directory with 4 updates Bumps the npm_and_yarn group with 4 updates in the / directory: [happy-dom](https://github.com/capricorn86/happy-dom), @isaacs/brace-expansion, [ajv](https://github.com/ajv-validator/ajv) and [minimatch](https://github.com/isaacs/minimatch). Updates `happy-dom` from 17.6.3 to 20.0.2 - [Release notes](https://github.com/capricorn86/happy-dom/releases) - [Commits](https://github.com/capricorn86/happy-dom/compare/v17.6.3...v20.0.2) Updates `@isaacs/brace-expansion` from 5.0.0 to 5.0.1 Updates `ajv` from 6.12.6 to 6.14.0 - [Release notes](https://github.com/ajv-validator/ajv/releases) - [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0) Updates `minimatch` from 3.1.2 to 3.1.3 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.3) --- updated-dependencies: - dependency-name: happy-dom dependency-version: 20.0.2 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: "@isaacs/brace-expansion" dependency-version: 5.0.1 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: ajv dependency-version: 6.14.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: minimatch dependency-version: 3.1.3 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 204 ++++++++++++++++++++++++++++--------------------- 2 files changed, 120 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index ee829eca..45adde96 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "eslint-plugin-sonarjs": "^3.0.6", "eslint-plugin-tailwindcss": "^3.18.2", "globals": "^17.2.0", - "happy-dom": "^17.4.6", + "happy-dom": "^20.0.2", "knip": "^5.82.1", "postcss": "^8.4.35", "prettier": "^3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756829c8..3f403379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,7 @@ importers: version: 4.7.0(vite@5.4.21(@types/node@25.0.7)(terser@5.46.0)) '@vitest/coverage-v8': specifier: ^3.1.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)) autoprefixer: specifier: ^10.4.17 version: 10.4.23(postcss@8.5.6) @@ -160,8 +160,8 @@ importers: specifier: ^17.2.0 version: 17.2.0 happy-dom: - specifier: ^17.4.6 - version: 17.6.3 + specifier: ^20.0.2 + version: 20.0.2 knip: specifier: ^5.82.1 version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3) @@ -191,7 +191,7 @@ importers: version: 5.4.21(@types/node@25.0.7)(terser@5.46.0) vitest: specifier: ^3.1.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0) packages: @@ -769,10 +769,6 @@ packages: resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/brace-expansion@5.0.1': resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} engines: {node: 20 || >=22} @@ -1171,6 +1167,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.33': + resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@24.10.12': resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==} @@ -1206,6 +1205,9 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1430,6 +1432,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1459,11 +1466,11 @@ packages: peerDependencies: ajv: ^6.9.1 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1641,6 +1648,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1674,6 +1685,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2550,6 +2565,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.2: @@ -2597,8 +2613,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@17.6.3: - resolution: {integrity: sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==} + happy-dom@20.0.2: + resolution: {integrity: sha512-pYOyu624+6HDbY+qkjILpQGnpvZOusItCk+rvF5/V+6NkcgTKnbOldpIy22tBnxoaLtlM9nXgoqAcW29/B7CIw==} engines: {node: '>=20.0.0'} has-bigints@1.1.0: @@ -3302,19 +3318,19 @@ packages: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.3: + resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + minimatch@5.1.7: + resolution: {integrity: sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==} engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.6: + resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -3964,6 +3980,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -4351,6 +4372,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4492,10 +4516,6 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -4758,8 +4778,8 @@ snapshots: '@develar/schema-utils@2.6.5': dependencies: - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) '@dnd-kit/accessibility@3.1.1(react@18.3.1)': dependencies: @@ -4790,7 +4810,7 @@ snapshots: dependencies: commander: 5.1.0 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.3 '@electron/get@2.0.3': dependencies: @@ -4871,7 +4891,7 @@ snapshots: debug: 4.4.3 dir-compare: 3.3.0 fs-extra: 9.1.0 - minimatch: 3.1.2 + minimatch: 3.1.3 plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -4883,7 +4903,7 @@ snapshots: debug: 4.4.3 dir-compare: 4.2.0 fs-extra: 11.3.3 - minimatch: 9.0.5 + minimatch: 9.0.6 plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -5068,7 +5088,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.3 transitivePeerDependencies: - supports-color @@ -5082,14 +5102,14 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.3 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -5107,8 +5127,8 @@ snapshots: '@fastify/ajv-compiler@4.0.5': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.1.0 '@fastify/cors@11.2.0': @@ -5165,10 +5185,6 @@ snapshots: '@isaacs/balanced-match@4.0.1': {} - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/brace-expansion@5.0.1': dependencies: '@isaacs/balanced-match': 4.0.1 @@ -5509,6 +5525,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.19.33': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.12': dependencies: undici-types: 7.16.0 @@ -5549,6 +5569,8 @@ snapshots: '@types/verror@1.10.11': optional: true + '@types/whatwg-mimetype@3.0.2': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 25.0.7 @@ -5621,7 +5643,7 @@ snapshots: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 9.0.6 semver: 7.7.3 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5718,7 +5740,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -5733,7 +5755,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0) transitivePeerDependencies: - supports-color @@ -5791,6 +5813,9 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: + optional: true + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -5808,22 +5833,22 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-formats@3.0.1(ajv@8.17.1): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: - ajv: 6.12.6 + ajv: 6.14.0 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -5876,10 +5901,10 @@ snapshots: isbinaryfile: 5.0.7 js-yaml: 4.1.1 lazy-val: 1.0.5 - minimatch: 5.1.6 + minimatch: 5.1.7 read-config-file: 6.3.2 sanitize-filename: 1.6.3 - semver: 7.7.3 + semver: 7.7.4 tar: 6.2.1 temp-file: 3.4.0 transitivePeerDependencies: @@ -5915,7 +5940,7 @@ snapshots: js-yaml: 4.1.1 json5: 2.2.3 lazy-val: 1.0.5 - minimatch: 10.1.2 + minimatch: 10.2.2 resedit: 1.7.2 sanitize-filename: 1.6.3 semver: 7.7.3 @@ -6099,6 +6124,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.3: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.14: {} @@ -6133,6 +6160,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.2: + dependencies: + balanced-match: 4.0.3 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -6520,11 +6551,11 @@ snapshots: dir-compare@3.3.0: dependencies: buffer-equal: 1.0.1 - minimatch: 3.1.2 + minimatch: 3.1.3 dir-compare@4.2.0: dependencies: - minimatch: 3.1.2 + minimatch: 3.1.3 p-limit: 3.1.0 dlv@1.1.3: {} @@ -6548,7 +6579,7 @@ snapshots: dependencies: '@types/plist': 3.0.5 '@types/verror': 1.10.11 - ajv: 6.12.6 + ajv: 6.14.0 crc: 3.8.0 iconv-corefoundation: 1.1.7 plist: 3.1.0 @@ -6928,7 +6959,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.3 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -6956,7 +6987,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.3 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -6988,7 +7019,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.3 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -7049,7 +7080,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -7068,7 +7099,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.3 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7136,8 +7167,8 @@ snapshots: fast-json-stringify@6.3.0: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.1.0 json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 @@ -7192,7 +7223,7 @@ snapshots: filelist@1.0.4: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.7 fill-range@7.1.1: dependencies: @@ -7351,14 +7382,14 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 + minimatch: 9.0.6 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 glob@13.0.2: dependencies: - minimatch: 10.1.2 + minimatch: 10.2.2 minipass: 7.1.2 path-scurry: 2.0.1 @@ -7367,7 +7398,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.3 once: 1.4.0 path-is-absolute: 1.0.1 @@ -7376,7 +7407,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 5.1.6 + minimatch: 5.1.7 once: 1.4.0 global-agent@3.0.0: @@ -7425,9 +7456,10 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@17.6.3: + happy-dom@20.0.2: dependencies: - webidl-conversions: 7.0.0 + '@types/node': 20.19.33 + '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 has-bigints@1.1.0: {} @@ -8351,24 +8383,24 @@ snapshots: mimic-response@3.1.0: {} minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - - minimatch@10.1.2: dependencies: '@isaacs/brace-expansion': 5.0.1 - minimatch@3.1.2: + minimatch@10.2.2: + dependencies: + brace-expansion: 5.0.2 + + minimatch@3.1.3: dependencies: brace-expansion: 1.1.12 - minimatch@5.1.6: + minimatch@5.1.7: dependencies: brace-expansion: 2.0.2 - minimatch@9.0.5: + minimatch@9.0.6: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.2 minimist@1.2.8: {} @@ -8832,7 +8864,7 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.7 readdirp@3.6.0: dependencies: @@ -9057,6 +9089,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -9383,7 +9417,7 @@ snapshots: terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -9392,7 +9426,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 9.0.5 + minimatch: 9.0.6 thenify-all@1.6.0: dependencies: @@ -9534,6 +9568,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} unified@11.0.5: @@ -9668,7 +9704,7 @@ snapshots: fsevents: 2.3.3 terser: 5.46.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)(terser@5.46.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -9696,7 +9732,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.0.7 - happy-dom: 17.6.3 + happy-dom: 20.0.2 transitivePeerDependencies: - less - lightningcss @@ -9714,8 +9750,6 @@ snapshots: dependencies: defaults: 1.0.4 - webidl-conversions@7.0.0: {} - whatwg-mimetype@3.0.0: {} which-boxed-primitive@1.1.1: From a039fdd5738167a9ff26f8d317d6f20b0b6e4253 Mon Sep 17 00:00:00 2001 From: KaustubhPatange Date: Sun, 22 Feb 2026 14:22:42 +0530 Subject: [PATCH 08/16] feat: add cost calculation metric --- .gitignore | 4 +- package.json | 7 + scripts/fetch-pricing-data.ts | 113 ++++++++++++++ src/main/utils/jsonl.ts | 143 +++++++++++++++++- src/renderer/components/chat/AIChatGroup.tsx | 4 + .../components/common/TokenUsageDisplay.tsx | 17 +++ src/shared/types/api.ts | 24 +++ src/shared/utils/costFormatting.ts | 45 ++++++ 8 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 scripts/fetch-pricing-data.ts create mode 100644 src/shared/utils/costFormatting.ts diff --git a/.gitignore b/.gitignore index 28223886..22230447 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ temp/ eslint-fix/ -remotion/* \ No newline at end of file +remotion/* + +resources/pricing.json diff --git a/package.json b/package.json index ee829eca..3cc5aa04 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "main": "dist-electron/main/index.cjs", "scripts": { "dev": "electron-vite dev", + "prebuild": "tsx scripts/fetch-pricing-data.ts", "build": "electron-vite build", "dist": "electron-builder --mac --win --linux", "dist:mac": "electron-builder --mac --publish always", @@ -127,6 +128,12 @@ "asarUnpack": [ "out/renderer/**" ], + "extraResources": [ + { + "from": "resources/pricing.json", + "to": "pricing.json" + } + ], "npmRebuild": false, "extraMetadata": { "main": "dist-electron/main/index.cjs" diff --git a/scripts/fetch-pricing-data.ts b/scripts/fetch-pricing-data.ts new file mode 100644 index 00000000..703a0e21 --- /dev/null +++ b/scripts/fetch-pricing-data.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env tsx + +/** + * Fetch latest model pricing from LiteLLM and save to renderer assets. + * Filters to Claude models only to reduce bundle size. + * Runs automatically during prebuild. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const LITELLM_PRICING_URL = + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'; +const OUTPUT_PATH = path.join(__dirname, '..', 'resources', 'pricing.json'); +const FETCH_TIMEOUT = 10000; // 10 seconds + +interface ModelPricing { + input_cost_per_token: number; + output_cost_per_token: number; + cache_creation_input_token_cost?: number; + cache_read_input_token_cost?: number; + input_cost_per_token_above_200k_tokens?: number; + output_cost_per_token_above_200k_tokens?: number; + cache_creation_input_token_cost_above_200k_tokens?: number; + cache_read_input_token_cost_above_200k_tokens?: number; + [key: string]: unknown; +} + +function isValidModelPricing(entry: unknown): entry is ModelPricing { + return ( + typeof entry === 'object' && + entry !== null && + 'input_cost_per_token' in entry && + 'output_cost_per_token' in entry && + typeof (entry as ModelPricing).input_cost_per_token === 'number' && + typeof (entry as ModelPricing).output_cost_per_token === 'number' + ); +} + +function isClaudeModel(modelName: string): boolean { + const lower = modelName.toLowerCase(); + return lower.includes('claude'); +} + +async function fetchPricingData(): Promise> { + console.log('Fetching pricing data from LiteLLM...'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + + try { + const response = await fetch(LITELLM_PRICING_URL, { signal: controller.signal }); + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = (await response.json()) as Record; + console.log(`Fetched pricing for ${Object.keys(data).length} models`); + + // Filter to Claude models only and validate entries + const claudeModels: Record = {}; + for (const [modelName, entry] of Object.entries(data)) { + if (isClaudeModel(modelName) && isValidModelPricing(entry)) { + claudeModels[modelName] = entry; + } + } + + console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`); + return claudeModels; + } catch (error) { + clearTimeout(timeout); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Fetch timeout after 10 seconds'); + } + throw error; + } +} + +async function main(): Promise { + try { + console.log('Fetching pricing data for models...'); + const pricing = await fetchPricingData(); + + // Ensure output directory exists + const outputDir = path.dirname(OUTPUT_PATH); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Write formatted JSON + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(pricing, null, 2), 'utf-8'); + + // Calculate file size + const stats = fs.statSync(OUTPUT_PATH); + const sizeKB = (stats.size / 1024).toFixed(2); + + console.log(`✓ Wrote pricing data to ${OUTPUT_PATH}`); + console.log(` Bundle size: ${sizeKB} KB`); + } catch (error) { + console.error('Failed to fetch pricing data:', error); + console.error('Build will continue with existing pricing.json if available'); + // Don't fail the build - allow using existing pricing.json + process.exit(0); + } +} + +main(); diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index eaf618f0..323bcf8d 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -212,6 +212,113 @@ function parseMessageType(type?: string): MessageType | null { } } +// ============================================================================= +// Cost Calculation +// ============================================================================= + +import * as fs from 'fs'; +import * as path from 'path'; + +interface ModelPricing { + input_cost_per_token: number; + output_cost_per_token: number; + cache_creation_input_token_cost?: number; + cache_read_input_token_cost?: number; + input_cost_per_token_above_200k_tokens?: number; + output_cost_per_token_above_200k_tokens?: number; + cache_creation_input_token_cost_above_200k_tokens?: number; + cache_read_input_token_cost_above_200k_tokens?: number; + [key: string]: unknown; +} + +const TIER_THRESHOLD = 200_000; + +// Cache pricing data in memory (loaded once on first use) +let pricingCache: Record | null = null; + +/** + * Load pricing data from resources directory. + * Uses electron-vite resource directory pattern: + * - Development: resources/pricing.json (project root) + * - Production: process.resourcesPath/pricing.json + */ +function loadPricingData(): Record { + if (pricingCache !== null) { + return pricingCache; + } + + try { + // Determine if we're in development or production + const isDev = process.env.NODE_ENV === 'development' || !process.resourcesPath; + + let pricingPath: string; + if (isDev) { + // Development: Compiled code is in dist-electron/main/ + // __dirname = /path/to/project/dist-electron/main + // Need to go up 2 levels to reach project root, then into resources/ + pricingPath = path.join(__dirname, '..', '..', 'resources', 'pricing.json'); + } else { + // Production: pricing.json in app's resources directory + pricingPath = path.join(process.resourcesPath, 'pricing.json'); + } + + const data = fs.readFileSync(pricingPath, 'utf-8'); + pricingCache = JSON.parse(data) as Record; + return pricingCache; + } catch (error) { + console.error('Failed to load pricing data:', error); + // Return empty object if pricing data can't be loaded + pricingCache = {}; + return pricingCache; + } +} + +function calculateTieredCost(tokens: number, baseRate: number, tieredRate?: number): number { + if (tokens <= 0) return 0; + if (!tieredRate || tokens <= TIER_THRESHOLD) { + return tokens * baseRate; + } + const costBelow = TIER_THRESHOLD * baseRate; + const costAbove = (tokens - TIER_THRESHOLD) * tieredRate; + return costBelow + costAbove; +} + +function getPricing(modelName: string): ModelPricing | null { + const pricing = loadPricingData(); + + const tryGet = (key: string): ModelPricing | null => { + const entry = pricing[key]; + if ( + entry && + typeof entry === 'object' && + 'input_cost_per_token' in entry && + 'output_cost_per_token' in entry + ) { + return entry as ModelPricing; + } + return null; + }; + + // Try exact match + const exact = tryGet(modelName); + if (exact) return exact; + + // Try lowercase + const lowerName = modelName.toLowerCase(); + const lower = tryGet(lowerName); + if (lower) return lower; + + // Try case-insensitive search + for (const key of Object.keys(pricing)) { + if (key.toLowerCase() === lowerName) { + const match = tryGet(key); + if (match) return match; + } + } + + return null; +} + // ============================================================================= // Metrics Calculation // ============================================================================= @@ -228,7 +335,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; - const costUsd = 0; + let modelName: string | undefined; // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); @@ -251,6 +358,38 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0; cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0; } + if (!modelName && msg.model) { + modelName = msg.model; + } + } + + // Calculate cost + let costUsd = 0; + if (modelName) { + const pricing = getPricing(modelName); + if (pricing) { + const inputCost = calculateTieredCost( + inputTokens, + pricing.input_cost_per_token, + pricing.input_cost_per_token_above_200k_tokens + ); + const outputCost = calculateTieredCost( + outputTokens, + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_200k_tokens + ); + const cacheCreationCost = calculateTieredCost( + cacheCreationTokens, + pricing.cache_creation_input_token_cost ?? 0, + pricing.cache_creation_input_token_cost_above_200k_tokens + ); + const cacheReadCost = calculateTieredCost( + cacheReadTokens, + pricing.cache_read_input_token_cost ?? 0, + pricing.cache_read_input_token_cost_above_200k_tokens + ); + costUsd = inputCost + outputCost + cacheCreationCost + cacheReadCost; + } } return { @@ -261,7 +400,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { cacheReadTokens, cacheCreationTokens, messageCount: messages.length, - costUsd: costUsd > 0 ? costUsd : undefined, + costUsd, }; } diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index 7bf24642..fb522b3b 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -245,6 +245,9 @@ const AIChatGroupInner = ({ return null; }, [aiGroup.responses]); + // Get the total cost + const costUSD = aiGroup.metrics.costUsd; + // Calculate thinking and text output tokens from assistant message content blocks // These are estimated from the actual content, providing breakdown of output token usage const { thinkingTokens, textOutputTokens } = useMemo(() => { @@ -470,6 +473,7 @@ const AIChatGroupInner = ({ contextStats={contextStats} phaseNumber={phaseNumber} totalPhases={totalPhases} + costUsd={costUSD} /> )} diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index d38690f7..70fb4887 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -11,6 +11,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { formatCostUsd } from '@shared/utils/costFormatting'; import { getModelColorClass } from '@shared/utils/modelParser'; import { formatTokensCompact as formatTokens, @@ -49,6 +50,8 @@ interface TokenUsageDisplayProps { phaseNumber?: number; /** Total number of phases in the session */ totalPhases?: number; + /** Optional USD cost for this usage */ + costUsd?: number; } /** @@ -255,6 +258,7 @@ export const TokenUsageDisplay = ({ contextStats, phaseNumber, totalPhases, + costUsd, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; const formattedTotal = formatTokens(totalTokens); @@ -513,6 +517,19 @@ export const TokenUsageDisplay = ({ + {/* Cost (USD) - if available */} + {costUsd !== undefined && costUsd > 0 && ( +
+ Cost (USD) + + {formatCostUsd(costUsd)} + +
+ )} + {/* Visible Context Breakdown - expandable section */} {contextStats && (contextStats.totalEstimatedTokens > 0 || diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index c81dbd8b..351cf3d1 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -29,6 +29,30 @@ import type { SubagentDetail, } from '@main/types'; +// ============================================================================= +// Cost Calculation Types +// ============================================================================= + +/** + * Detailed cost breakdown by token type for a session or chunk + */ +export interface CostBreakdown { + /** Cost for input tokens */ + inputCost: number; + /** Cost for output tokens */ + outputCost: number; + /** Cost for cache creation tokens */ + cacheCreationCost: number; + /** Cost for cache read tokens */ + cacheReadCost: number; + /** Total cost (sum of all components) */ + totalCost: number; + /** Model name used for calculation */ + model: string; + /** Source of the cost data */ + source: 'calculated' | 'precalculated' | 'unavailable'; +} + // ============================================================================= // Agent Config // ============================================================================= diff --git a/src/shared/utils/costFormatting.ts b/src/shared/utils/costFormatting.ts new file mode 100644 index 00000000..3cbedd44 --- /dev/null +++ b/src/shared/utils/costFormatting.ts @@ -0,0 +1,45 @@ +/** + * Cost formatting utilities + */ + +/** + * Format USD cost with appropriate precision + * - $0.001 or more: 2 decimal places ($1.23) + * - Less than $0.001: 3-4 decimal places for precision ($0.0012) + * - Zero: $0.00 + */ +export function formatCostUsd(cost: number): string { + if (cost === 0) { + return '$0.00'; + } + + if (cost >= 0.01) { + // Standard currency format for amounts >= 1 cent + return `$${cost.toFixed(2)}`; + } else if (cost >= 0.001) { + // 3 decimal places for sub-cent amounts + return `$${cost.toFixed(3)}`; + } else { + // 4 decimal places for very small amounts + return `$${cost.toFixed(4)}`; + } +} + +/** + * Format cost compactly for display in badges + * - Rounds to 2 decimal places + * - Omits $ prefix for brevity + */ +export function formatCostCompact(cost: number): string { + if (cost === 0) { + return '0.00'; + } + + if (cost >= 0.01) { + return cost.toFixed(2); + } else if (cost >= 0.001) { + return cost.toFixed(3); + } else { + return cost.toFixed(4); + } +} From 2bb95db5963e64f18656cd31f8a5ac92e97d86f4 Mon Sep 17 00:00:00 2001 From: KaustubhPatange Date: Sun, 22 Feb 2026 14:49:11 +0530 Subject: [PATCH 09/16] feat: add tests for cost calculation --- .gitignore | 2 - resources/pricing.json | 4195 +++++++++++++++++++++++ src/main/types/chunks.ts | 1 + test/main/utils/costCalculation.test.ts | 529 +++ 4 files changed, 4725 insertions(+), 2 deletions(-) create mode 100644 resources/pricing.json create mode 100644 test/main/utils/costCalculation.test.ts diff --git a/.gitignore b/.gitignore index 22230447..6afcf5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,3 @@ temp/ eslint-fix/ remotion/* - -resources/pricing.json diff --git a/resources/pricing.json b/resources/pricing.json new file mode 100644 index 00000000..9687baaa --- /dev/null +++ b/resources/pricing.json @@ -0,0 +1,4195 @@ +{ + "anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "anthropic.claude-haiku-4-5@20251001": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_streaming": true + }, + "anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.00003, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "cache_creation_input_token_cost_above_1hr": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000015, + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7 + }, + "anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.00003, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "cache_creation_input_token_cost_above_1hr": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000015 + }, + "anthropic.claude-3-7-sonnet-20240620-v1:0": { + "cache_creation_input_token_cost": 0.0000045, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0000024, + "supports_tool_choice": true + }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "global.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "eu.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "au.anthropic.claude-opus-4-6-v1": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "global.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "eu.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "apac.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024 + }, + "anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "apac.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "azure_ai/claude-haiku-4-5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-opus-4-5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-opus-4-6": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "azure_ai/claude-opus-4-1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-sonnet-4-5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "bedrock/ap-northeast-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 0.00000223, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.00000755, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 0.00000248, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.00000838, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024 + }, + "bedrock/eu-central-1/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Anthropic via Invoke route does not currently support pdf input." + }, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-east-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0000024, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 3e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { + "input_cost_per_token": 0.0000033, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 0.0000045, + "cache_read_input_token_cost": 3.6e-7, + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.0000036, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000018, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 3e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { + "input_cost_per_token": 0.0000033, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-west-2/anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0000024, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-v1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-v2:1": { + "input_cost_per_token": 0.000008, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.000024, + "supports_tool_choice": true + }, + "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "claude-3-5-haiku-20241022": { + "cache_creation_input_token_cost": 0.000001, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 8e-8, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 8e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-5-haiku-latest": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 1e-7, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 0.000001, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-haiku-4-5-20251001": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_computer_use": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-haiku-4-5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_computer_use": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-3-5-sonnet-20240620": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-20241022": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-latest": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-20250219": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2026-02-19", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-latest": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-haiku-20240307": { + "cache_creation_input_token_cost": 3e-7, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-opus-20240229": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 0.0000015, + "deprecation_date": "2026-05-01", + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-3-opus-latest": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 0.0000015, + "deprecation_date": "2025-03-01", + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-4-opus-20250514": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-4-sonnet-20250514": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-sonnet-4-5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-5-20250929": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us/claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost": 3.3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "inference_geo": "us" + }, + "claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-1-20250805": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "deprecation_date": "2026-08-05", + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-20250514": { + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "deprecation_date": "2026-05-14", + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-5-20251101": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "fast/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.00003, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "fast/us/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.00003, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "fast/claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.00003, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us/claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_200k_tokens": 0.00001375, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.0000011, + "input_cost_per_token": 0.0000055, + "input_cost_per_token_above_200k_tokens": 0.000011, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "output_cost_per_token_above_200k_tokens": 0.00004125, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-20250514": { + "deprecation_date": "2026-05-14", + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "databricks/databricks-claude-3-7-sonnet": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-haiku-4-5": { + "input_cost_per_token": 0.00000100002, + "input_dbu_cost_per_token": 0.000014286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.00000500003, + "output_dbu_cost_per_token": 0.000071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4": { + "input_cost_per_token": 0.000015000020000000002, + "input_dbu_cost_per_token": 0.000214286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.00007500003000000001, + "output_dbu_cost_per_token": 0.001071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4-1": { + "input_cost_per_token": 0.000015000020000000002, + "input_dbu_cost_per_token": 0.000214286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.00007500003000000001, + "output_dbu_cost_per_token": 0.001071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4-5": { + "input_cost_per_token": 0.00000500003, + "input_dbu_cost_per_token": 0.000071429, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000025000010000000002, + "output_dbu_cost_per_token": 0.000357143, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4-1": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4-5": { + "input_cost_per_token": 0.0000029999900000000002, + "input_dbu_cost_per_token": 0.000042857, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.000015000020000000002, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-3-7-sonnet-latest": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "cache_read_input_token_cost": 3.3e-7, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-4-opus": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 0.0000165, + "output_cost_per_token": 0.0000825, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-4-sonnet": { + "max_tokens": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "input_cost_per_token": 0.0000033, + "output_cost_per_token": 0.0000165, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_tool_choice": true + }, + "eu.anthropic.claude-3-5-haiku-20241022-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "deprecation_date": "2026-10-15", + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "gmi/anthropic/claude-opus-4.5": { + "input_cost_per_token": 0.000005, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_function_calling": true, + "supports_vision": true + }, + "gmi/anthropic/claude-sonnet-4.5": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_vision": true + }, + "gmi/anthropic/claude-sonnet-4": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_vision": true + }, + "gmi/anthropic/claude-opus-4": { + "input_cost_per_token": 0.000015, + "litellm_provider": "gmi", + "max_input_tokens": 409600, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_vision": true + }, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "global.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "global.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "gradient_ai/anthropic-claude-3-opus": { + "input_cost_per_token": 0.000015, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.5-haiku": { + "input_cost_per_token": 8e-7, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.5-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.7-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "openrouter/anthropic/claude-3-haiku": { + "input_cost_per_image": 0.0004, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "openrouter", + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/anthropic/claude-3.5-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-3.7-sonnet": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 0.000003, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4.1": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00001875, + "cache_creation_input_token_cost_above_1hr": 0.00003, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-sonnet-4": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4.5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-sonnet-4.5": { + "input_cost_per_image": 0.0048, + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-haiku-4.5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "replicate/anthropic/claude-4.5-haiku": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-4-sonnet": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-3.7-sonnet": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-3.5-haiku": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000005, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-3.5-sonnet": { + "input_cost_per_token": 0.00000375, + "output_cost_per_token": 0.00001875, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "replicate/anthropic/claude-4.5-sonnet": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "litellm_provider": "replicate", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "us.anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 0.000003, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 0.000004125, + "cache_read_input_token_cost": 3.3e-7, + "input_cost_per_token": 0.0000033, + "input_cost_per_token_above_200k_tokens": 0.0000066, + "output_cost_per_token_above_200k_tokens": 0.00002475, + "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000165, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "au.anthropic.claude-haiku-4-5-20251001-v1:0": { + "cache_creation_input_token_cost": 0.000001375, + "cache_read_input_token_cost": 1.1e-7, + "input_cost_per_token": 0.0000011, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000055, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "global.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vercel_ai_gateway/anthropic/claude-3-haiku": { + "cache_creation_input_token_cost": 3e-7, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3-opus": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3.5-haiku": { + "cache_creation_input_token_cost": 0.000001, + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 8e-7, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000004, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3.5-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-3.7-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-4-opus": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_vision": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true + }, + "vercel_ai_gateway/anthropic/claude-4-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vercel_ai_gateway/anthropic/claude-3-5-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-3-5-sonnet-20241022": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-3-7-sonnet": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-haiku-4.5": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4.1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4.5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-opus-4.6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-sonnet-4": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/anthropic/claude-sonnet-4.5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-haiku": { + "input_cost_per_token": 0.000001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true + }, + "vertex_ai/claude-3-5-haiku@20241022": { + "input_cost_per_token": 0.000001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true + }, + "vertex_ai/claude-haiku-4-5@20251001": { + "cache_creation_input_token_cost": 0.00000125, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 0.000001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000005, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/haiku-4-5", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_native_streaming": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet-v2": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet-v2@20241022": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet@20240620": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-7-sonnet@20250219": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-3-haiku": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-haiku@20240307": { + "input_cost_per_token": 2.5e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00000125, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-opus": { + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-opus@20240229": { + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000075, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-sonnet": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-sonnet@20240229": { + "input_cost_per_token": 0.000003, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.000015, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-opus-4-1": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "input_cost_per_token_batches": 0.0000075, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "output_cost_per_token_batches": 0.0000375, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4-1@20250805": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "input_cost_per_token_batches": 0.0000075, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "output_cost_per_token_batches": 0.0000375, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4-5": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-opus-4-5@20251101": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159, + "supports_native_streaming": true + }, + "vertex_ai/claude-opus-4-6": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-opus-4-6@default": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + "input_cost_per_token": 0.000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-sonnet-4-5": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token_batches": 0.0000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_batches": 0.0000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-sonnet-4-6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + } + }, + "vertex_ai/claude-sonnet-4-5@20250929": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token_batches": 0.0000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_batches": 0.0000075, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_streaming": true + }, + "vertex_ai/claude-opus-4@20250514": { + "cache_creation_input_token_cost": 0.00001875, + "cache_read_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000015, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.000075, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4@20250514": { + "cache_creation_input_token_cost": 0.00000375, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4-6@default": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + } + } +} \ No newline at end of file diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index dd1b94ad..545d3bce 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -461,6 +461,7 @@ export const EMPTY_METRICS: SessionMetrics = { cacheReadTokens: 0, cacheCreationTokens: 0, messageCount: 0, + costUsd: 0, }; // ============================================================================= diff --git a/test/main/utils/costCalculation.test.ts b/test/main/utils/costCalculation.test.ts new file mode 100644 index 00000000..04a56044 --- /dev/null +++ b/test/main/utils/costCalculation.test.ts @@ -0,0 +1,529 @@ +/** + * Tests for cost calculation in jsonl.ts + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fs from 'fs'; +import { calculateMetrics } from '@main/utils/jsonl'; +import type { ParsedMessage } from '@main/types'; + +// Mock fs module +vi.mock('fs'); + +describe('Cost Calculation', () => { + // Sample pricing data matching Claude models + const mockPricingData = { + 'claude-3-5-sonnet-20241022': { + input_cost_per_token: 0.000003, + output_cost_per_token: 0.000015, + cache_creation_input_token_cost: 0.00000375, + cache_read_input_token_cost: 0.0000003, + input_cost_per_token_above_200k_tokens: 0.000006, + output_cost_per_token_above_200k_tokens: 0.00003, + cache_creation_input_token_cost_above_200k_tokens: 0.0000075, + cache_read_input_token_cost_above_200k_tokens: 0.0000006, + }, + 'claude-3-opus-20240229': { + input_cost_per_token: 0.000015, + output_cost_per_token: 0.000075, + cache_creation_input_token_cost: 0.00001875, + cache_read_input_token_cost: 0.0000015, + }, + }; + + beforeEach(() => { + // Reset modules to clear pricing cache + vi.resetModules(); + + // Mock fs.readFileSync to return our test pricing data + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockPricingData)); + vi.mocked(fs.existsSync).mockReturnValue(true); + }); + + describe('Basic Cost Calculation', () => { + it('should calculate cost for simple token usage', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Expected: (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105 + expect(metrics.costUsd).toBeCloseTo(0.0105, 6); + }); + + it('should calculate cost with cache tokens', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: 1000 * 0.000003 = 0.003 + // Output: 500 * 0.000015 = 0.0075 + // Cache creation: 200 * 0.00000375 = 0.00075 + // Cache read: 300 * 0.0000003 = 0.00009 + // Total: 0.01134 + expect(metrics.costUsd).toBeCloseTo(0.01134, 6); + }); + + it('should return 0 cost when no model is specified', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should return 0 cost when model pricing not found', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'unknown-model', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + }); + + describe('Tiered Pricing', () => { + it('should use base rates for tokens below 200k threshold', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 100_000, + output_tokens: 50_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: 100000 * 0.000003 = 0.3 + // Output: 50000 * 0.000015 = 0.75 + // Total: 1.05 + expect(metrics.costUsd).toBeCloseTo(1.05, 6); + }); + + it('should use tiered rates for input tokens above 200k threshold', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 250_000, + output_tokens: 1_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: (200000 * 0.000003) + (50000 * 0.000006) = 0.6 + 0.3 = 0.9 + // Output: 1000 * 0.000015 = 0.015 + // Total: 0.915 + expect(metrics.costUsd).toBeCloseTo(0.915, 6); + }); + + it('should use tiered rates for output tokens above 200k threshold', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1_000, + output_tokens: 250_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: 1000 * 0.000003 = 0.003 + // Output: (200000 * 0.000015) + (50000 * 0.00003) = 3.0 + 1.5 = 4.5 + // Total: 4.503 + expect(metrics.costUsd).toBeCloseTo(4.503, 6); + }); + + it('should use tiered rates for cache tokens above 200k threshold', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1_000, + output_tokens: 1_000, + cache_creation_input_tokens: 250_000, + cache_read_input_tokens: 250_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Input: 1000 * 0.000003 = 0.003 + // Output: 1000 * 0.000015 = 0.015 + // Cache creation: (200000 * 0.00000375) + (50000 * 0.0000075) = 0.75 + 0.375 = 1.125 + // Cache read: (200000 * 0.0000003) + (50000 * 0.0000006) = 0.06 + 0.03 = 0.09 + // Total: 1.233 + expect(metrics.costUsd).toBeCloseTo(1.233, 6); + }); + + it('should handle model without tiered pricing', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-opus-20240229', + usage: { + input_tokens: 250_000, + output_tokens: 250_000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // No tiered rates, so use base rates even above 200k + // Input: 250000 * 0.000015 = 3.75 + // Output: 250000 * 0.000075 = 18.75 + // Total: 22.5 + expect(metrics.costUsd).toBeCloseTo(22.5, 6); + }); + }); + + describe('Multiple Messages', () => { + it('should aggregate costs across multiple messages', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + { + type: 'assistant', + uuid: 'msg-2', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 2000, + output_tokens: 1000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Message 1: (1000 * 0.000003) + (500 * 0.000015) = 0.0105 + // Message 2: (2000 * 0.000003) + (1000 * 0.000015) = 0.021 + // Total: 0.0315 + expect(metrics.costUsd).toBeCloseTo(0.0315, 6); + }); + + it('should use first model found when calculating aggregated cost', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + { + type: 'assistant', + uuid: 'msg-2', + timestamp: new Date(), + content: [], + model: 'claude-3-opus-20240229', // Different model + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Uses first model (sonnet) pricing for all tokens + // Total tokens: 2000 input, 1000 output + // Cost: (2000 * 0.000003) + (1000 * 0.000015) = 0.006 + 0.015 = 0.021 + expect(metrics.costUsd).toBeCloseTo(0.021, 6); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero tokens', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 0, + output_tokens: 0, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should handle messages without usage data', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should handle empty messages array', () => { + const messages: ParsedMessage[] = []; + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + }); + + it('should handle pricing data load failure gracefully', async () => { + // Suppress expected console.error for this test + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Reset modules to clear the pricing cache + vi.resetModules(); + + // Mock fs to throw error BEFORE importing calculateMetrics + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); + + // Re-import calculateMetrics to get fresh instance with cleared cache + const { calculateMetrics: freshCalculateMetrics } = await import('@main/utils/jsonl'); + + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = freshCalculateMetrics(messages); + expect(metrics.costUsd).toBe(0); + + // Verify that console.error was called (error was logged) + expect(consoleErrorSpy).toHaveBeenCalled(); + + // Restore console.error + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Model Name Lookup', () => { + it('should find model with exact match', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBeGreaterThan(0); + }); + + it('should find model with case-insensitive match', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'CLAUDE-3-5-SONNET-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + expect(metrics.costUsd).toBeGreaterThan(0); + }); + }); + + describe('Integration with Other Metrics', () => { + it('should include cost alongside other session metrics', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Check that all expected metrics are present + expect(metrics).toHaveProperty('totalTokens'); + expect(metrics).toHaveProperty('inputTokens'); + expect(metrics).toHaveProperty('outputTokens'); + expect(metrics).toHaveProperty('costUsd'); + expect(metrics.totalTokens).toBe(1500); + expect(metrics.inputTokens).toBe(1000); + expect(metrics.outputTokens).toBe(500); + expect(metrics.costUsd).toBeCloseTo(0.0105, 6); + }); + }); +}); From 723760938ea11db21eb961bb58de1505667bbfc0 Mon Sep 17 00:00:00 2001 From: KaustubhPatange Date: Sun, 22 Feb 2026 15:18:52 +0530 Subject: [PATCH 10/16] feat: add session cost in session header --- .gitignore | 2 +- src/main/utils/jsonl.ts | 77 +++--- src/renderer/components/chat/ChatHistory.tsx | 1 + .../components/SessionContextHeader.tsx | 22 ++ .../chat/SessionContextPanel/index.tsx | 2 + .../chat/SessionContextPanel/types.ts | 8 +- test/main/utils/costCalculation.test.ts | 81 ++++++- test/shared/utils/costFormatting.test.ts | 228 ++++++++++++++++++ 8 files changed, 379 insertions(+), 42 deletions(-) create mode 100644 test/shared/utils/costFormatting.test.ts diff --git a/.gitignore b/.gitignore index 6afcf5ce..28223886 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,4 @@ temp/ eslint-fix/ -remotion/* +remotion/* \ No newline at end of file diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 323bcf8d..13034ea1 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -351,47 +351,58 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { } } + // Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals) + let costUsd = 0; + for (const msg of messages) { if (msg.usage) { - inputTokens += msg.usage.input_tokens ?? 0; - outputTokens += msg.usage.output_tokens ?? 0; - cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0; - cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0; + const msgInputTokens = msg.usage.input_tokens ?? 0; + const msgOutputTokens = msg.usage.output_tokens ?? 0; + const msgCacheReadTokens = msg.usage.cache_read_input_tokens ?? 0; + const msgCacheCreationTokens = msg.usage.cache_creation_input_tokens ?? 0; + + inputTokens += msgInputTokens; + outputTokens += msgOutputTokens; + cacheReadTokens += msgCacheReadTokens; + cacheCreationTokens += msgCacheCreationTokens; + + // Calculate cost for this message if we have pricing data + if (msg.model && !modelName) { + modelName = msg.model; + } + + if (msg.model) { + const pricing = getPricing(msg.model); + if (pricing) { + const inputCost = calculateTieredCost( + msgInputTokens, + pricing.input_cost_per_token, + pricing.input_cost_per_token_above_200k_tokens + ); + const outputCost = calculateTieredCost( + msgOutputTokens, + pricing.output_cost_per_token, + pricing.output_cost_per_token_above_200k_tokens + ); + const cacheCreationCost = calculateTieredCost( + msgCacheCreationTokens, + pricing.cache_creation_input_token_cost ?? 0, + pricing.cache_creation_input_token_cost_above_200k_tokens + ); + const cacheReadCost = calculateTieredCost( + msgCacheReadTokens, + pricing.cache_read_input_token_cost ?? 0, + pricing.cache_read_input_token_cost_above_200k_tokens + ); + costUsd += inputCost + outputCost + cacheCreationCost + cacheReadCost; + } + } } if (!modelName && msg.model) { modelName = msg.model; } } - // Calculate cost - let costUsd = 0; - if (modelName) { - const pricing = getPricing(modelName); - if (pricing) { - const inputCost = calculateTieredCost( - inputTokens, - pricing.input_cost_per_token, - pricing.input_cost_per_token_above_200k_tokens - ); - const outputCost = calculateTieredCost( - outputTokens, - pricing.output_cost_per_token, - pricing.output_cost_per_token_above_200k_tokens - ); - const cacheCreationCost = calculateTieredCost( - cacheCreationTokens, - pricing.cache_creation_input_token_cost ?? 0, - pricing.cache_creation_input_token_cost_above_200k_tokens - ); - const cacheReadCost = calculateTieredCost( - cacheReadTokens, - pricing.cache_read_input_token_cost ?? 0, - pricing.cache_read_input_token_cost_above_200k_tokens - ); - costUsd = inputCost + outputCost + cacheCreationCost + cacheReadCost; - } - } - return { durationMs: maxTime - minTime, totalTokens: inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens, diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index a6a4acbe..24644b50 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -824,6 +824,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { onNavigateToTool={handleNavigateToTool} onNavigateToUserGroup={handleNavigateToUserGroup} totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={sessionDetail?.metrics} phaseInfo={sessionPhaseInfo ?? undefined} selectedPhase={selectedContextPhase} onPhaseChange={setSelectedContextPhase} diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index ae9da2e1..d0791e00 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -12,6 +12,7 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; +import { formatCostUsd } from '@shared/utils/costFormatting'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; @@ -19,12 +20,14 @@ import { formatTokens } from '../utils/formatting'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; import type { ContextViewMode } from '../types'; +import type { SessionMetrics } from '@main/types'; import type { ContextPhaseInfo } from '@renderer/types/contextInjection'; interface SessionContextHeaderProps { injectionCount: number; totalTokens: number; totalSessionTokens?: number; + sessionMetrics?: SessionMetrics; onClose?: () => void; phaseInfo?: ContextPhaseInfo; selectedPhase: number | null; @@ -37,6 +40,7 @@ export const SessionContextHeader = ({ injectionCount, totalTokens, totalSessionTokens, + sessionMetrics, onClose, phaseInfo, selectedPhase, @@ -115,6 +119,24 @@ export const SessionContextHeader = ({ )} + {/* Session Metrics Breakdown */} + {sessionMetrics && ( +
+ {/* Cost */} + {sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && ( +
+ Session Cost: + + {formatCostUsd(sessionMetrics.costUsd)} + +
+ )} +
+ )} + {/* Phase selector - only shown when compactions exist */} {phaseInfo && phaseInfo.phases.length > 1 && (
void; /** Total session tokens (input + output + cache) for comparison */ totalSessionTokens?: number; + /** Full session metrics (input, output, cache tokens, cost) */ + sessionMetrics?: SessionMetrics; /** Phase information for phase selector */ phaseInfo?: ContextPhaseInfo; /** Currently selected phase (null = current/latest) */ diff --git a/test/main/utils/costCalculation.test.ts b/test/main/utils/costCalculation.test.ts index 04a56044..42bd4d79 100644 --- a/test/main/utils/costCalculation.test.ts +++ b/test/main/utils/costCalculation.test.ts @@ -317,7 +317,7 @@ describe('Cost Calculation', () => { expect(metrics.costUsd).toBeCloseTo(0.0315, 6); }); - it('should use first model found when calculating aggregated cost', () => { + it("should calculate cost per-message using each message's model", () => { const messages: ParsedMessage[] = [ { type: 'assistant', @@ -351,10 +351,11 @@ describe('Cost Calculation', () => { const metrics = calculateMetrics(messages); - // Uses first model (sonnet) pricing for all tokens - // Total tokens: 2000 input, 1000 output - // Cost: (2000 * 0.000003) + (1000 * 0.000015) = 0.006 + 0.015 = 0.021 - expect(metrics.costUsd).toBeCloseTo(0.021, 6); + // Each message uses its own model's pricing + // Message 1 (sonnet): (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105 + // Message 2 (opus): (1000 * 0.000015) + (500 * 0.000075) = 0.015 + 0.0375 = 0.0525 + // Total cost: 0.0105 + 0.0525 = 0.063 + expect(metrics.costUsd).toBeCloseTo(0.063, 6); }); }); @@ -494,6 +495,76 @@ describe('Cost Calculation', () => { }); }); + describe('Per-Message Tiering', () => { + it('should apply tiered pricing per-message, not to aggregated totals', () => { + // Scenario: Many messages each with cache_read tokens < 200k, + // but aggregated total > 200k + // Each message should use base rates, not tiered rates + const messages: ParsedMessage[] = []; + + // Create 10 messages, each with 50k cache_read tokens (500k total) + for (let i = 0; i < 10; i++) { + messages.push({ + type: 'assistant', + uuid: `msg-${i}`, + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 50000, + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }); + } + + const metrics = calculateMetrics(messages); + + // Per-message tiering: Each message uses base rate (< 200k threshold) + // Each message: 50,000 * 0.0000003 = $0.015 + // Total: 10 * $0.015 = $0.15 + const expectedCost = 10 * 50000 * 0.0000003; + expect(metrics.costUsd).toBeCloseTo(expectedCost, 6); + + // Verify this is NOT using tiered rate on aggregated total + // If incorrectly aggregated: (200k * 0.0000003) + (300k * 0.0000006) = $0.24 + const incorrectAggregatedCost = 0.24; + expect(metrics.costUsd).not.toBeCloseTo(incorrectAggregatedCost, 2); + }); + + it('should apply tiered rates when individual messages exceed 200k', () => { + const messages: ParsedMessage[] = [ + { + type: 'assistant', + uuid: 'msg-1', + timestamp: new Date(), + content: [], + model: 'claude-3-5-sonnet-20241022', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 300000, // Exceeds 200k threshold + }, + toolCalls: [], + toolResults: [], + isSidechain: false, + }, + ]; + + const metrics = calculateMetrics(messages); + + // Single message with 300k cache_read tokens + // First 200k: 200,000 * 0.0000003 = $0.06 + // Remaining 100k: 100,000 * 0.0000006 = $0.06 + // Total: $0.12 + const expectedCost = 200000 * 0.0000003 + 100000 * 0.0000006; + expect(metrics.costUsd).toBeCloseTo(expectedCost, 6); + }); + }); + describe('Integration with Other Metrics', () => { it('should include cost alongside other session metrics', () => { const messages: ParsedMessage[] = [ diff --git a/test/shared/utils/costFormatting.test.ts b/test/shared/utils/costFormatting.test.ts new file mode 100644 index 00000000..10679009 --- /dev/null +++ b/test/shared/utils/costFormatting.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for cost formatting utilities + */ + +import { describe, it, expect } from 'vitest'; +import { formatCostUsd, formatCostCompact } from '@shared/utils/costFormatting'; + +describe('Cost Formatting', () => { + describe('formatCostUsd', () => { + describe('Zero values', () => { + it('should format zero as $0.00', () => { + expect(formatCostUsd(0)).toBe('$0.00'); + }); + + it('should format negative zero as $0.00', () => { + expect(formatCostUsd(-0)).toBe('$0.00'); + }); + }); + + describe('Standard amounts (>= $0.01)', () => { + it('should format 1 cent with 2 decimal places', () => { + expect(formatCostUsd(0.01)).toBe('$0.01'); + }); + + it('should format 1 dollar with 2 decimal places', () => { + expect(formatCostUsd(1.0)).toBe('$1.00'); + }); + + it('should format dollars and cents', () => { + expect(formatCostUsd(1.23)).toBe('$1.23'); + }); + + it('should format large amounts', () => { + expect(formatCostUsd(999.99)).toBe('$999.99'); + expect(formatCostUsd(1234.56)).toBe('$1234.56'); + }); + + it('should round to 2 decimal places for amounts >= 1 cent', () => { + expect(formatCostUsd(1.234)).toBe('$1.23'); + expect(formatCostUsd(1.235)).toBe('$1.24'); // Rounds up + expect(formatCostUsd(1.999)).toBe('$2.00'); + }); + }); + + describe('Sub-cent amounts ($0.001 - $0.01)', () => { + it('should format 1 tenth of a cent with 3 decimal places', () => { + expect(formatCostUsd(0.001)).toBe('$0.001'); + }); + + it('should format sub-cent amounts with 3 decimal places', () => { + expect(formatCostUsd(0.005)).toBe('$0.005'); + expect(formatCostUsd(0.009)).toBe('$0.009'); + }); + + it('should round to 3 decimal places for sub-cent amounts', () => { + expect(formatCostUsd(0.0012)).toBe('$0.001'); + expect(formatCostUsd(0.0015)).toBe('$0.002'); // Rounds up + expect(formatCostUsd(0.0099)).toBe('$0.010'); + }); + }); + + describe('Very small amounts (< $0.001)', () => { + it('should format tiny amounts with 4 decimal places', () => { + expect(formatCostUsd(0.0001)).toBe('$0.0001'); + expect(formatCostUsd(0.0005)).toBe('$0.0005'); + expect(formatCostUsd(0.0009)).toBe('$0.0009'); + }); + + it('should round to 4 decimal places for tiny amounts', () => { + expect(formatCostUsd(0.00012)).toBe('$0.0001'); + expect(formatCostUsd(0.00016)).toBe('$0.0002'); // Rounds up + expect(formatCostUsd(0.00099)).toBe('$0.0010'); + }); + + it('should handle very tiny amounts', () => { + expect(formatCostUsd(0.000001)).toBe('$0.0000'); + }); + }); + + describe('Edge cases', () => { + it('should handle negative amounts with 4 decimal places', () => { + // Negative numbers don't match >= comparisons, so they use 4 decimals + expect(formatCostUsd(-1.23)).toBe('$-1.2300'); + expect(formatCostUsd(-0.001)).toBe('$-0.0010'); + expect(formatCostUsd(-0.0001)).toBe('$-0.0001'); + }); + + it('should handle very large amounts', () => { + expect(formatCostUsd(1000000)).toBe('$1000000.00'); + }); + + it('should handle precision boundaries', () => { + // Boundary between 2 and 3 decimal places + expect(formatCostUsd(0.01)).toBe('$0.01'); + expect(formatCostUsd(0.00999)).toBe('$0.010'); // Just below threshold, uses 3 decimals + + // Boundary between 3 and 4 decimal places + expect(formatCostUsd(0.001)).toBe('$0.001'); + expect(formatCostUsd(0.00099)).toBe('$0.0010'); // Just below threshold, uses 4 decimals + }); + }); + + describe('Real-world API cost examples', () => { + it('should format typical Claude API costs', () => { + // 1M input tokens at $3.00/M + expect(formatCostUsd(3.0)).toBe('$3.00'); + + // 100k input tokens at $3.00/M + expect(formatCostUsd(0.3)).toBe('$0.30'); + + // 10k cache read tokens at $0.30/M + expect(formatCostUsd(0.003)).toBe('$0.003'); + + // 1k cache read tokens at $0.30/M + expect(formatCostUsd(0.0003)).toBe('$0.0003'); + }); + + it('should format session totals', () => { + // Small session + expect(formatCostUsd(0.15)).toBe('$0.15'); + + // Medium session + expect(formatCostUsd(5.67)).toBe('$5.67'); + + // Large session + expect(formatCostUsd(29.57)).toBe('$29.57'); + }); + }); + }); + + describe('formatCostCompact', () => { + describe('Zero values', () => { + it('should format zero as 0.00', () => { + expect(formatCostCompact(0)).toBe('0.00'); + }); + + it('should format negative zero as 0.00', () => { + expect(formatCostCompact(-0)).toBe('0.00'); + }); + }); + + describe('Standard amounts (>= $0.01)', () => { + it('should format amounts without $ prefix', () => { + expect(formatCostCompact(0.01)).toBe('0.01'); + expect(formatCostCompact(1.0)).toBe('1.00'); + expect(formatCostCompact(1.23)).toBe('1.23'); + }); + + it('should format large amounts', () => { + expect(formatCostCompact(999.99)).toBe('999.99'); + expect(formatCostCompact(1234.56)).toBe('1234.56'); + }); + + it('should round to 2 decimal places', () => { + expect(formatCostCompact(1.234)).toBe('1.23'); + expect(formatCostCompact(1.235)).toBe('1.24'); // Rounds up + expect(formatCostCompact(1.999)).toBe('2.00'); + }); + }); + + describe('Sub-cent amounts ($0.001 - $0.01)', () => { + it('should format sub-cent amounts with 3 decimal places', () => { + expect(formatCostCompact(0.001)).toBe('0.001'); + expect(formatCostCompact(0.005)).toBe('0.005'); + expect(formatCostCompact(0.009)).toBe('0.009'); + }); + + it('should round to 3 decimal places', () => { + expect(formatCostCompact(0.0012)).toBe('0.001'); + expect(formatCostCompact(0.0015)).toBe('0.002'); // Rounds up + expect(formatCostCompact(0.0099)).toBe('0.010'); + }); + }); + + describe('Very small amounts (< $0.001)', () => { + it('should format tiny amounts with 4 decimal places', () => { + expect(formatCostCompact(0.0001)).toBe('0.0001'); + expect(formatCostCompact(0.0005)).toBe('0.0005'); + expect(formatCostCompact(0.0009)).toBe('0.0009'); + }); + + it('should round to 4 decimal places', () => { + expect(formatCostCompact(0.00012)).toBe('0.0001'); + expect(formatCostCompact(0.00016)).toBe('0.0002'); // Rounds up + expect(formatCostCompact(0.00099)).toBe('0.0010'); + }); + }); + + describe('Edge cases', () => { + it('should handle negative amounts with 4 decimal places', () => { + // Negative numbers don't match >= comparisons, so they use 4 decimals + expect(formatCostCompact(-1.23)).toBe('-1.2300'); + expect(formatCostCompact(-0.001)).toBe('-0.0010'); + expect(formatCostCompact(-0.0001)).toBe('-0.0001'); + }); + + it('should handle very large amounts', () => { + expect(formatCostCompact(1000000)).toBe('1000000.00'); + }); + }); + + describe('Comparison with formatCostUsd', () => { + it('should match formatCostUsd except for $ prefix', () => { + const testCases = [0, 0.0001, 0.001, 0.01, 1.23, 999.99]; + + testCases.forEach((cost) => { + const withPrefix = formatCostUsd(cost); + const compact = formatCostCompact(cost); + + // Compact should equal the USD format without the $ + expect(compact).toBe(withPrefix.substring(1)); + }); + }); + }); + + describe('Badge display use cases', () => { + it('should format for badge display', () => { + // Small per-message costs + expect(formatCostCompact(0.0015)).toBe('0.002'); + expect(formatCostCompact(0.01)).toBe('0.01'); + + // Session totals in badges + expect(formatCostCompact(2.5)).toBe('2.50'); + expect(formatCostCompact(15.0)).toBe('15.00'); + }); + }); + }); +}); From 5932009c3b96906b07f094abff9bea35e6f7d579 Mon Sep 17 00:00:00 2001 From: KaustubhPatange Date: Sun, 22 Feb 2026 15:57:54 +0530 Subject: [PATCH 11/16] fix: lint issues --- src/main/utils/jsonl.ts | 3 --- .../components/SessionContextHeader.tsx | 2 +- src/renderer/components/chat/SessionContextPanel/types.ts | 7 ++++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 13034ea1..114090d0 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -398,9 +398,6 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { } } } - if (!modelName && msg.model) { - modelName = msg.model; - } } return { diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index d0791e00..f3a8ca00 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -20,8 +20,8 @@ import { formatTokens } from '../utils/formatting'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; import type { ContextViewMode } from '../types'; -import type { SessionMetrics } from '@main/types'; import type { ContextPhaseInfo } from '@renderer/types/contextInjection'; +import type { SessionMetrics } from '@shared/types'; interface SessionContextHeaderProps { injectionCount: number; diff --git a/src/renderer/components/chat/SessionContextPanel/types.ts b/src/renderer/components/chat/SessionContextPanel/types.ts index 10558188..009629e1 100644 --- a/src/renderer/components/chat/SessionContextPanel/types.ts +++ b/src/renderer/components/chat/SessionContextPanel/types.ts @@ -2,12 +2,13 @@ * Type definitions for SessionContextPanel components. */ +import type { ClaudeMdSource } from '@renderer/types/claudeMd'; +import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection'; +import type { SessionMetrics } from '@shared/types'; + // ============================================================================= // Props Interface // ============================================================================= -import type { SessionMetrics } from '@main/types'; -import type { ClaudeMdSource } from '@renderer/types/claudeMd'; -import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection'; export interface SessionContextPanelProps { /** All accumulated context injections */ From a7d7bacf3b8cddc9cec8b5d06dda2b8e932ff998 Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Sun, 22 Feb 2026 05:40:39 -0800 Subject: [PATCH 12/16] fix: guard Notification.isSupported for standalone/Docker (#42) Add typeof checks before calling Notification.isSupported() to prevent crashes in environments where the Notification API is unavailable. --- src/main/services/infrastructure/NotificationManager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 6eba59a6..a92db953 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -373,8 +373,12 @@ export class NotificationManager extends EventEmitter { * Shows a native macOS notification for an error. */ private showNativeNotification(error: DetectedError): void { - // Check if Notification is supported - if (!Notification.isSupported()) { + // Guard against standalone/Docker mode where Electron's Notification API is unavailable + if ( + typeof Notification === 'undefined' || + typeof Notification.isSupported !== 'function' || + !Notification.isSupported() + ) { logger.warn('Native notifications not supported'); return; } From 258dc6d82f3d987a12d94b517a9ab4efa2c81406 Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Sun, 22 Feb 2026 05:36:18 -0800 Subject: [PATCH 13/16] fix: prevent Ctrl+R page reload and show platform-aware shortcuts (#58) Intercept Ctrl+R/Cmd+R in before-input-event to prevent Electron's default page reload. Replace hardcoded macOS shortcut symbols with platform-aware helpers that show Ctrl+ on Windows/Linux. --- src/main/index.ts | 11 +++++++++- .../components/dashboard/DashboardView.tsx | 3 ++- .../components/layout/SidebarHeader.tsx | 4 ++-- src/renderer/components/layout/TabBar.tsx | 5 +++-- .../components/layout/TabContextMenu.tsx | 8 ++++--- .../components/sidebar/SessionContextMenu.tsx | 3 ++- src/renderer/utils/stringUtils.ts | 21 +++++++++++++++++++ 7 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 6484e149..3b2dcdca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -481,7 +481,16 @@ function createWindow(): void { const ZOOM_OUT_KEYS = new Set(['-', '_']); mainWindow.webContents.on('before-input-event', (event, input) => { if (!mainWindow || mainWindow.isDestroyed()) return; - if (!input.meta || input.type !== 'keyDown') return; + if (input.type !== 'keyDown') return; + + // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer + // keyboard handler can use it as "Refresh Session" (fixes #58). + if ((input.control || input.meta) && input.key.toLowerCase() === 'r') { + event.preventDefault(); + return; + } + + if (!input.meta) return; const currentLevel = mainWindow.webContents.getZoomLevel(); diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index a85afb05..a427bb54 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -11,6 +11,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { formatShortcut } from '@renderer/utils/stringUtils'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; @@ -75,7 +76,7 @@ const CommandSearch = ({ value, onChange }: Readonly): React diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 6748a0a5..dfc458c8 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -14,6 +14,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; +import { formatShortcut } from '@renderer/utils/stringUtils'; import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -330,7 +331,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { onMouseEnter={() => setRefreshHover(true)} onMouseLeave={() => setRefreshHover(false)} onClick={handleRefresh} - title="Refresh Session (Cmd+R)" + title={`Refresh Session (${formatShortcut('R')})`} > @@ -367,7 +368,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { color: searchHover ? 'var(--color-text)' : 'var(--color-text-muted)', backgroundColor: searchHover ? 'var(--color-surface-raised)' : 'transparent', }} - title="Search (Cmd+K)" + title={`Search (${formatShortcut('K')})`} > diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx index cd3fedf8..17f8bf0a 100644 --- a/src/renderer/components/layout/TabContextMenu.tsx +++ b/src/renderer/components/layout/TabContextMenu.tsx @@ -7,6 +7,8 @@ import { useEffect, useRef } from 'react'; +import { formatShortcut } from '@renderer/utils/stringUtils'; + interface TabContextMenuProps { x: number; y: number; @@ -100,13 +102,13 @@ export const TabContextMenu = ({ onClick={handleClick(onCloseSelectedTabs)} /> ) : ( - + )}
@@ -127,7 +129,7 @@ export const TabContextMenu = ({ /> )}
- +
); }; diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx index 4adbf6ee..626cc6a5 100644 --- a/src/renderer/components/sidebar/SessionContextMenu.tsx +++ b/src/renderer/components/sidebar/SessionContextMenu.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import { MAX_PANES } from '@renderer/types/panes'; +import { formatShortcut } from '@renderer/utils/stringUtils'; import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react'; interface SessionContextMenuProps { @@ -98,7 +99,7 @@ export const SessionContextMenu = ({ }} > - +
Date: Sun, 22 Feb 2026 19:13:14 -0500 Subject: [PATCH 14/16] fix: reliable window drag region in tab bar Includes scroll improvements: - Scroll to bottom on session open and live auto-scroll - Make auto-scroll StrictMode-safe via needsInitialScrollRef - Add floating scroll-to-bottom button in chat view Window drag fix: - Apply drag region to leftmost pane TabBar regardless of sidebar state - Cap tab list at 75% so drag spacer always has room - Add explicit flex-1 drag spacer between tabs and action buttons - Set WebkitAppRegion: no-drag on tab items for reordering Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/components/chat/ChatHistory.tsx | 49 +++++++++++++++++-- .../components/layout/SortableTab.tsx | 3 +- src/renderer/components/layout/TabBar.tsx | 16 ++++-- src/renderer/hooks/useAutoScrollBottom.ts | 39 ++++++++++----- 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index a6a4acbe..3fa7a2a7 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; +import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; import { useStore } from '@renderer/store'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { ChevronsDown } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionContextPanel } from './SessionContextPanel/index'; @@ -343,11 +344,21 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { rootRef: scrollContainerRef, }); + // Scroll-to-bottom button visibility + const [showScrollButton, setShowScrollButton] = useState(false); + + const checkScrollButton = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + const { scrollTop, scrollHeight, clientHeight } = container; + setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, 300)); + }, []); + // Auto-follow when conversation updates, but only if the user was already near bottom. // This preserves manual reading position when the user scrolls up. // Disabled during navigation to prevent conflicts with deep-link/search scrolling. - useAutoScrollBottom([conversation], { - threshold: 150, + const { scrollToBottom } = useAutoScrollBottom([conversation], { + threshold: 300, smoothDuration: 300, autoBehavior: 'auto', disabled: shouldDisableAutoScroll, @@ -355,6 +366,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { resetKey: effectiveTabId, }); + // Re-check button visibility whenever conversation updates + useEffect(() => { + checkScrollButton(); + }, [conversation, checkScrollButton]); + // Callback to register AI group refs (combines with visibility hook) const registerAIGroupRefCombined = useCallback( (groupId: string) => { @@ -718,12 +734,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { className="flex flex-1 flex-col overflow-hidden" style={{ backgroundColor: 'var(--color-surface)' }} > -
+
{/* Chat content */}
{/* Sticky Context button */} {allContextInjections.length > 0 && ( @@ -813,6 +830,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
+ {/* Scroll to bottom button */} + {showScrollButton && ( + + )} + {/* Context panel sidebar */} {isContextPanelVisible && allContextInjections.length > 0 && (
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 1a9758c4..472c617c 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -60,7 +60,8 @@ export const SortableTab = ({ }, }); - const style: React.CSSProperties = { + const style = { + WebkitAppRegion: 'no-drag', transform: CSS.Transform.toString(transform), transition: isDragging ? 'none' : transition, opacity: isDragging ? 0.3 : 1, diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 61915d35..2fc3b8a5 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -268,8 +268,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { sidebarCollapsed && isLeftmostPane ? 'var(--macos-traffic-light-padding-left, 72px)' : '8px', - WebkitAppRegion: - isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined, + WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined, backgroundColor: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)', opacity: isFocused || paneCount === 1 ? 1 : 0.7, @@ -296,15 +295,17 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )} - {/* Tab list with horizontal scroll, sortable DnD, and droppable area */} + {/* Tab list with horizontal scroll, sortable DnD, and droppable area. + Capped at 75% so the drag spacer always has room to the right. */}
{ scrollContainerRef.current = el; setDroppableRef(el); }} - className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" + className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto" style={ { + maxWidth: '75%', WebkitAppRegion: 'no-drag', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outlineOffset: '-1px', @@ -346,6 +347,13 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )}
+ {/* Drag spacer — fills empty space between tab list and action buttons. + Gives users a reliable window-drag target regardless of how many tabs are open. */} +
+ {/* Right side actions */}
{ if (resetKey !== prevResetKeyRef.current) { isAtBottomRef.current = true; wasAtBottomBeforeUpdateRef.current = true; prevResetKeyRef.current = resetKey; + needsInitialScrollRef.current = true; } }, [resetKey]); /** - * After content updates (dependencies change), scroll to bottom if we were at bottom. + * After content updates (dependencies change), scroll to bottom if: + * - User was already near the bottom before the update, OR + * - This is the first load after a tab/session switch (needsInitialScrollRef) + * Uses double-RAF + cleanup so React StrictMode's double-invoke doesn't fire twice. */ useEffect(() => { // Skip if disabled (e.g., during navigation) or not enabled if (!enabled || disabled) return; - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - // Re-check disabled state inside RAF - it might have changed between effect and callback - // This prevents auto-scroll from firing if navigation started after the effect ran - if (disabledRef.current) return; + let id1 = 0; + let id2 = 0; - // Only auto-scroll if user was at bottom before the update - if (wasAtBottomBeforeUpdateRef.current) { - scrollToBottom(autoBehavior); - } + id1 = requestAnimationFrame(() => { + id2 = requestAnimationFrame(() => { + // Re-check disabled state — navigation may have started between effect and RAF + if (disabledRef.current) return; + + const shouldScroll = needsInitialScrollRef.current || wasAtBottomBeforeUpdateRef.current; + if (shouldScroll) { + needsInitialScrollRef.current = false; + scrollToBottom(autoBehavior); + } + }); }); + + return () => { + cancelAnimationFrame(id1); + cancelAnimationFrame(id2); + }; // eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design }, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]); From 93b515af40b4a2ed7aed3e27d82be409e1e5df62 Mon Sep 17 00:00:00 2001 From: proxy Date: Sun, 22 Feb 2026 19:13:28 -0500 Subject: [PATCH 15/16] feat: add auto-expand AI response groups setting - Add toggle in settings to auto-expand AI response groups - Auto-expand new AI groups on live session refresh Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 ++ src/main/ipc/configValidation.ts | 7 ++++ .../services/infrastructure/ConfigManager.ts | 2 + .../settings/hooks/useSettingsConfig.ts | 2 + .../settings/hooks/useSettingsHandlers.ts | 1 + .../settings/sections/GeneralSection.tsx | 13 +++++- .../store/slices/sessionDetailSlice.ts | 40 +++++++++++++++++++ src/shared/types/notifications.ts | 2 + test/main/ipc/configValidation.test.ts | 22 ++++++++++ 9 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e741060..8d370fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] ### Added +- `general.autoExpandAIGroups` setting: automatically expands all AI response groups when opening a transcript or when new AI responses arrive in a live session. Defaults to off. Stored in the on-disk config so it persists across restarts. + + - Strict IPC input validation guards for project/session/subagent/search limits. - `get-waterfall-data` IPC endpoint implementation. - Cross-platform path normalization in renderer path resolvers. diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 469d7c33..b860bd15 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -203,6 +203,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V 'theme', 'defaultTab', 'claudeRootPath', + 'autoExpandAIGroups', ]; const result: Partial = {}; @@ -267,6 +268,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V result.claudeRootPath = path.resolve(normalized); } break; + case 'autoExpandAIGroups': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.autoExpandAIGroups = value; + break; default: return { valid: false, error: `Unsupported general key: ${key}` }; } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index b8999536..7f3ea4a3 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -181,6 +181,7 @@ export interface GeneralConfig { theme: 'dark' | 'light' | 'system'; defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; + autoExpandAIGroups: boolean; } export interface DisplayConfig { @@ -248,6 +249,7 @@ const DEFAULT_CONFIG: AppConfig = { theme: 'dark', defaultTab: 'dashboard', claudeRootPath: null, + autoExpandAIGroups: false, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 6d3f9c6a..8f4fbdc4 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -30,6 +30,7 @@ export interface SafeConfig { theme: 'dark' | 'light' | 'system'; defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; + autoExpandAIGroups: boolean; }; notifications: { enabled: boolean; @@ -154,6 +155,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { theme: displayConfig?.general?.theme ?? 'dark', defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, + autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false, }, notifications: { enabled: displayConfig?.notifications?.enabled ?? true, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 5d6941b0..40a9997f 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -287,6 +287,7 @@ export function useSettingsHandlers({ theme: 'dark', defaultTab: 'dashboard', claudeRootPath: null, + autoExpandAIGroups: false, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index cd28a176..deca9d3f 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -15,6 +15,7 @@ import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } fro import type { SafeConfig } from '../hooks/useSettingsConfig'; import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types'; import type { HttpServerStatus } from '@shared/types/api'; +import type { AppConfig } from '@shared/types/notifications'; // Theme options const THEME_OPTIONS = [ @@ -26,7 +27,7 @@ const THEME_OPTIONS = [ interface GeneralSectionProps { readonly safeConfig: SafeConfig; readonly saving: boolean; - readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void; + readonly onGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void; readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void; } @@ -286,6 +287,16 @@ export const GeneralSection = ({ disabled={saving} /> + + onGeneralToggle('autoExpandAIGroups', v)} + disabled={saving} + /> + {isElectron && ( <> diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 9a158f49..7a1de354 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -416,6 +416,15 @@ export const createSessionDetailSlice: StateCreator item.type === 'ai') + .map((item) => (item as { type: 'ai'; group: { id: string } }).group.id) + ); + // Update only the data, preserve UI states set((state) => ({ sessionDetail: detail, @@ -572,6 +589,29 @@ export const createSessionDetailSlice: StateCreator + item.type === 'ai' && + !oldGroupIds.has((item as { type: 'ai'; group: { id: string } }).group.id) + ) + .map((item) => (item as { type: 'ai'; group: { id: string } }).group.id); + + if (newGroupIds.length > 0) { + for (const tab of latestAllTabs) { + if (tab.type === 'session' && tab.sessionId === sessionId) { + for (const groupId of newGroupIds) { + get().expandAIGroupForTab(tab.id, groupId); + } + } + } + } + } + // Also update per-tab session data for all tabs viewing this session const latestTabSessionData = { ...get().tabSessionData }; for (const tab of latestAllTabs) { diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 245663ab..10dadc50 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -262,6 +262,8 @@ export interface AppConfig { defaultTab: 'dashboard' | 'last-session'; /** Optional custom Claude root folder (auto-detected when null) */ claudeRootPath: string | null; + /** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */ + autoExpandAIGroups: boolean; }; /** Display and UI settings */ display: { diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 0dbd7707..4dcb9714 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -20,6 +20,28 @@ describe('configValidation', () => { } }); + it('accepts general.autoExpandAIGroups boolean toggle', () => { + const resultOn = validateConfigUpdatePayload('general', { autoExpandAIGroups: true }); + expect(resultOn.valid).toBe(true); + if (resultOn.valid) { + expect(resultOn.data).toEqual({ autoExpandAIGroups: true }); + } + + const resultOff = validateConfigUpdatePayload('general', { autoExpandAIGroups: false }); + expect(resultOff.valid).toBe(true); + if (resultOff.valid) { + expect(resultOff.data).toEqual({ autoExpandAIGroups: false }); + } + }); + + it('rejects non-boolean general.autoExpandAIGroups', () => { + const result = validateConfigUpdatePayload('general', { autoExpandAIGroups: 'yes' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('boolean'); + } + }); + it('accepts absolute general.claudeRootPath updates', () => { const result = validateConfigUpdatePayload('general', { claudeRootPath: '/Users/test/.claude', From dbdfc275266437b36f47899d8c5cd08bf9cecb79 Mon Sep 17 00:00:00 2001 From: proxy Date: Sun, 22 Feb 2026 19:47:17 -0500 Subject: [PATCH 16/16] refactor: address code review findings from Gemini Code Assist - Extract SCROLL_THRESHOLD (300px) constant so auto-scroll hook and scroll-button visibility logic stay in sync - Extract CONTEXT_PANEL_WIDTH_PX (320px) constant to avoid layout drift if the context panel width is ever adjusted - Gate drag spacer on isElectronMode() && isLeftmostPane to match the TabBar drag region logic and prevent unintended drag regions in split-pane layouts Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/components/chat/ChatHistory.tsx | 12 +++++++++--- src/renderer/components/layout/TabBar.tsx | 9 +++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 3fa7a2a7..867df2aa 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -10,6 +10,12 @@ import { ChevronsDown } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionContextPanel } from './SessionContextPanel/index'; + +/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */ +const SCROLL_THRESHOLD = 300; +/** Must match the `w-80` (320px) context panel width used in the layout below. */ +const CONTEXT_PANEL_WIDTH_PX = 320; + import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; @@ -351,14 +357,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const container = scrollContainerRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; - setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, 300)); + setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, SCROLL_THRESHOLD)); }, []); // Auto-follow when conversation updates, but only if the user was already near bottom. // This preserves manual reading position when the user scrolls up. // Disabled during navigation to prevent conflicts with deep-link/search scrolling. const { scrollToBottom } = useAutoScrollBottom([conversation], { - threshold: 300, + threshold: SCROLL_THRESHOLD, smoothDuration: 300, autoBehavior: 'auto', disabled: shouldDisableAutoScroll, @@ -841,7 +847,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { style={{ right: isContextPanelVisible && allContextInjections.length > 0 - ? 'calc(320px + 1rem)' + ? `calc(${CONTEXT_PANEL_WIDTH_PX}px + 1rem)` : '1rem', backgroundColor: 'var(--context-btn-bg)', color: 'var(--color-text-secondary)', diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 2fc3b8a5..d3a81536 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -348,10 +348,15 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
{/* Drag spacer — fills empty space between tab list and action buttons. - Gives users a reliable window-drag target regardless of how many tabs are open. */} + Gives users a reliable window-drag target regardless of how many tabs are open. + Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
{/* Right side actions */}