Skip to content
39 changes: 30 additions & 9 deletions server/modules/providers/list/claude/claude-models.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';

export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
import { DEFAULT_CLAUDE_MODEL_ENV, resolveDefaultClaudeModel } from './default-claude-model.js';

const BASE_CLAUDE_MODEL_OPTIONS: ProviderModelOption[] = [
{
value: 'default',
label: 'Default (recommended)',
Expand All @@ -31,9 +32,12 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
},
},
{
value: 'fable',
// divizend: exact API model id rather than a generic 'fable' alias that
// could silently drift to a different snapshot later. Selectable, but no
// longer the default (see DEFAULT below) — it burns tokens far faster.
value: 'claude-fable-5-1',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
description: 'Fable 5.1 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
effort: {
default: 'high',
values: [
Expand All @@ -46,9 +50,10 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
},
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
// divizend: exact API model id, not a generic alias that could drift.
value: 'claude-sonnet-5',
label: 'Sonnet',
description: 'Sonnet 5 · Best for everyday tasks',
effort: {
default: 'high',
values: [
Expand Down Expand Up @@ -108,8 +113,24 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
label: 'Haiku',
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
},
],
DEFAULT: 'default',
];

// divizend: the default is not a constant in this file any more — it comes from
// cloud-admin-box's `cloud-admin-box-claude-model` Secret via
// CLOUDCLI_DEFAULT_CLAUDE_MODEL (baked-in fallback: claude-sonnet-5). The 'default'
// OPTION entry above is untouched, so explicitly picking "Default (recommended)"
// still behaves as upstream intended.
const resolvedDefault = resolveDefaultClaudeModel(
process.env[DEFAULT_CLAUDE_MODEL_ENV],
BASE_CLAUDE_MODEL_OPTIONS,
);
if (resolvedDefault.warning) {
console.warn(`[Claude models] ${resolvedDefault.warning}`);
}

export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: resolvedDefault.options,
DEFAULT: resolvedDefault.defaultModel,
};

export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => {
Expand Down
186 changes: 120 additions & 66 deletions server/modules/providers/list/claude/claude-sessions.provider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';

import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
Expand All @@ -10,6 +9,94 @@ import { sessionsDb } from '@/modules/database/index.js';

const PROVIDER = 'claude';

// ─── Incremental, cached JSONL line reads ───────────────────────────────────
//
// getSessionMessages() is invoked on every `/messages` request regardless of
// the caller's requested limit (fetchHistory always loads full raw history
// first — see below), including on every filesystem-watcher-triggered
// refresh while a session is actively being written to. Re-reading and
// re-JSON.parse-ing the entire transcript (and every referenced subagent
// file) from byte 0 on each of those calls scales with total session size,
// not with how much actually changed since the last call. These caches let
// repeat calls reuse already-parsed lines and only read the bytes appended
// since the last read.

type LineCacheEntry = {
size: number;
mtimeMs: number;
lines: AnyRecord[];
trailingPartialLine: string;
};

const LINE_CACHE_MAX_ENTRIES = 100;
const lineCache = new Map<string, LineCacheEntry>();

function touchLineCache(filePath: string, entry: LineCacheEntry): void {
// Map preserves insertion order; delete-then-set moves `filePath` to the
// most-recently-used end so eviction below drops the least-recently-used.
lineCache.delete(filePath);
lineCache.set(filePath, entry);
while (lineCache.size > LINE_CACHE_MAX_ENTRIES) {
const oldestKey = lineCache.keys().next().value;
if (oldestKey === undefined) break;
lineCache.delete(oldestKey);
}
}

/**
* Returns every parsed JSON line of `filePath`, reusing a cached parse when
* the file hasn't changed and reading only the newly-appended bytes when it
* has grown. Falls back to a full re-read when the file shrank (rotated/
* truncated/rewritten) — not expected for Claude's append-only transcripts,
* but a stale, wrong cache is worse than an occasional extra full read.
*/
async function getCachedLines(filePath: string): Promise<AnyRecord[]> {
let stat: { size: number; mtimeMs: number };
try {
stat = await fsp.stat(filePath);
} catch {
return [];
}

const cached = lineCache.get(filePath);
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
return cached.lines;
}

const canAppend = Boolean(cached) && stat.size > cached!.size;
const readStart = canAppend ? cached!.size : 0;
const lines: AnyRecord[] = canAppend ? cached!.lines : [];
let buffer = canAppend ? cached!.trailingPartialLine : '';

await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath, { start: readStart, encoding: 'utf8' });
stream.on('data', (chunk: string | Buffer) => {
buffer += chunk;
const parts = buffer.split('\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
if (!part.trim()) continue;
try {
lines.push(JSON.parse(part) as AnyRecord);
} catch {
// Skip malformed JSONL lines that can happen during concurrent writes.
}
}
});
stream.on('end', resolve);
stream.on('error', reject);
});

touchLineCache(filePath, {
size: stat.size,
mtimeMs: stat.mtimeMs,
lines,
trailingPartialLine: buffer,
});

return lines;
}

type ClaudeToolResult = {
content: unknown;
isError: boolean;
Expand Down Expand Up @@ -39,58 +126,44 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
const tools: AnyRecord[] = [];

try {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});

for await (const line of rl) {
if (!line.trim()) {
continue;
}
const entries = await getCachedLines(filePath);

try {
const entry = JSON.parse(line) as AnyRecord;

if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content as AnyRecord[]) {
if (part.type === 'tool_use') {
tools.push({
toolId: part.id,
toolName: part.name,
toolInput: part.input,
timestamp: entry.timestamp,
});
}
for (const entry of entries) {
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content as AnyRecord[]) {
if (part.type === 'tool_use') {
tools.push({
toolId: part.id,
toolName: part.name,
toolInput: part.input,
timestamp: entry.timestamp,
});
}
}
}

if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content as AnyRecord[]) {
if (part.type !== 'tool_result') {
continue;
}
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content as AnyRecord[]) {
if (part.type !== 'tool_result') {
continue;
}

const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
if (!tool) {
continue;
}
const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
if (!tool) {
continue;
}

tool.toolResult = {
content: typeof part.content === 'string'
tool.toolResult = {
content: typeof part.content === 'string'
? part.content
: Array.isArray(part.content)
? part.content
: Array.isArray(part.content)
? part.content
.map((contentPart: AnyRecord) => contentPart?.text || '')
.join('\n')
: JSON.stringify(part.content),
isError: Boolean(part.is_error),
};
}
.map((contentPart: AnyRecord) => contentPart?.text || '')
.join('\n')
: JSON.stringify(part.content),
isError: Boolean(part.is_error),
};
}
} catch {
// Skip malformed lines that can happen during concurrent writes.
}
}
} catch (error) {
Expand Down Expand Up @@ -120,29 +193,10 @@ async function getSessionMessages(
const files = await fsp.readdir(projectDir);
const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-'));

const messages: AnyRecord[] = [];
const agentToolsCache = new Map<string, AnyRecord[]>();

const fileStream = fs.createReadStream(jsonLPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});

for await (const line of rl) {
if (!line.trim()) {
continue;
}

try {
const entry = JSON.parse(line) as AnyRecord;
if (entry.sessionId === providerSessionId) {
messages.push(entry);
}
} catch {
// Skip malformed JSONL lines that can happen during concurrent writes.
}
}
const allEntries = await getCachedLines(jsonLPath);
const messages = allEntries.filter((entry) => entry.sessionId === providerSessionId);

const agentIds = new Set<string>();
for (const message of messages) {
Expand Down
52 changes: 52 additions & 0 deletions server/modules/providers/list/claude/default-claude-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ProviderModelOption } from '@/shared/types.js';

/**
* cloud-admin-box wires its Kubernetes Secret `cloud-admin-box-claude-model`
* into this env var. Read once at process start: a switch rollout-restarts
* the pod, so there is no live re-read to get wrong.
*/
export const DEFAULT_CLAUDE_MODEL_ENV = 'CLOUDCLI_DEFAULT_CLAUDE_MODEL';
export const BAKED_IN_DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5';
/** Exact API model ids only (e.g. claude-sonnet-5, claude-fable-5-1, claude-sonnet-5[1m]). */
export const CLAUDE_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*(\[1m\])?$/;

export type ResolvedDefaultClaudeModel = {
defaultModel: string;
options: ProviderModelOption[];
warning?: string;
};

export function resolveDefaultClaudeModel(
envValue: string | undefined,
baseOptions: ProviderModelOption[],
): ResolvedDefaultClaudeModel {
const value = envValue?.trim() ?? '';
if (!value) {
return { defaultModel: BAKED_IN_DEFAULT_CLAUDE_MODEL, options: baseOptions };
}
if (!CLAUDE_MODEL_ID_PATTERN.test(value)) {
return {
defaultModel: BAKED_IN_DEFAULT_CLAUDE_MODEL,
options: baseOptions,
warning: `${DEFAULT_CLAUDE_MODEL_ENV}="${value}" is not a valid Claude model id; using ${BAKED_IN_DEFAULT_CLAUDE_MODEL}`,
};
}
if (baseOptions.some((option) => option.value === value)) {
return { defaultModel: value, options: baseOptions };
}
return {
defaultModel: value,
options: [
...baseOptions,
{
value,
label: value,
description: `Configured via ${DEFAULT_CLAUDE_MODEL_ENV}`,
effort: {
default: 'high',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'max' }],
},
},
],
};
}
Loading