feat(models): resolve active session models through provider adapters

The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved
This commit is contained in:
Haileyesus
2026-05-18 14:54:32 +03:00
parent 556cbd1a03
commit bc5e768579
13 changed files with 537 additions and 52 deletions

View File

@@ -1,8 +1,16 @@
import { spawn } from 'node:child_process';
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
import crossSpawn from 'cross-spawn';
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
import type { IProviderModels } from '@/shared/interfaces.js';
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
import type {
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
} from '@/shared/types.js';
import { buildDefaultProviderCurrentActiveModel } from '@/shared/utils.js';
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
@@ -17,6 +25,14 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
};
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
type ClaudeInitEvent = {
type?: string;
subtype?: string;
model?: string;
};
const CLAUDE_ACTIVE_MODEL_TIMEOUT_MS = 20_000;
const claudeSpawn = process.platform === 'win32' ? crossSpawn : spawn;
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
env: { ...process.env },
@@ -58,6 +74,84 @@ const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinit
};
};
const runClaudeSessionModelCommand = async (sessionId: string): Promise<ProviderCurrentActiveModel | null> => {
const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
return new Promise((resolve, reject) => {
const child = claudeSpawn(
cliPath,
['-p', '--verbose', '--output-format', 'stream-json', '--resume', sessionId, 'ok'],
{
env: { ...process.env },
windowsHide: true,
},
);
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
child.kill('SIGTERM');
if (!settled) {
settled = true;
reject(new Error('Claude current-model lookup timed out'));
}
}, CLAUDE_ACTIVE_MODEL_TIMEOUT_MS);
const finish = (error: Error | null, result: ProviderCurrentActiveModel | null) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (error) {
reject(error);
return;
}
resolve(result);
};
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
finish(error instanceof Error ? error : new Error(String(error)), null);
});
child.on('close', () => {
const lines = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line) as ClaudeInitEvent;
if (event.type === 'system' && event.subtype === 'init' && event.model) {
finish(null, {
model: event.model,
});
return;
}
} catch {
// The Claude CLI mixes non-JSON lines into verbose output; ignore them.
}
}
finish(null, null);
});
});
};
export class ClaudeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
let queryInstance: ReturnType<typeof query> | null = null;
@@ -80,4 +174,21 @@ export class ClaudeProviderModels implements IProviderModels {
queryInstance?.close();
}
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
if (!sessionId?.trim()) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
try {
const activeModel = await runClaudeSessionModelCommand(sessionId);
if (activeModel?.model) {
return activeModel;
}
} catch {
// Fall through to the provider default when the session-backed lookup fails.
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
}

View File

@@ -2,9 +2,19 @@ import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import TOML from '@iarna/toml';
import type { IProviderModels } from '@/shared/interfaces.js';
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
import type {
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
readObjectRecord,
readOptionalString,
} from '@/shared/utils.js';
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
@@ -27,6 +37,7 @@ type CodexCachedModel = {
};
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
const isCodexCachedModel = (value: unknown): value is CodexCachedModel => {
const record = readObjectRecord(value);
@@ -85,4 +96,21 @@ export class CodexProviderModels implements IProviderModels {
return CODEX_FALLBACK_MODELS;
}
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
try {
const raw = await readFile(CODEX_CONFIG_PATH, 'utf8');
const parsed = readObjectRecord(TOML.parse(raw));
const model = readOptionalString(parsed?.model);
if (!model) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
return {
model,
};
} catch {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
}
}

View File

@@ -1,9 +1,20 @@
import { access, readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn';
import type { IProviderModels } from '@/shared/interfaces.js';
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
import type {
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
sanitizeLeafDirectoryName,
} from '@/shared/utils.js';
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
@@ -24,6 +35,7 @@ type CursorModelRow = {
};
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const ANSI_PATTERN = new RegExp(
// eslint-disable-next-line no-control-regex
@@ -167,6 +179,31 @@ const buildCursorModelsDefinition = (models: CursorModelRow[]): ProviderModelsDe
};
};
const resolveCursorSessionStorePath = async (sessionId: string): Promise<string | null> => {
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
try {
const workspaceEntries = await readdir(CURSOR_CHATS_ROOT, { withFileTypes: true });
for (const workspaceEntry of workspaceEntries) {
if (!workspaceEntry.isDirectory()) {
continue;
}
const storeDbPath = path.join(CURSOR_CHATS_ROOT, workspaceEntry.name, safeSessionId, 'store.db');
try {
await access(storeDbPath);
return storeDbPath;
} catch {
// Keep scanning sibling workspaces until the matching session directory is found.
}
}
} catch {
return null;
}
return null;
};
export class CursorProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
try {
@@ -177,4 +214,47 @@ export class CursorProviderModels implements IProviderModels {
return CURSOR_FALLBACK_MODELS;
}
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
if (!sessionId?.trim()) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
try {
const storeDbPath = await resolveCursorSessionStorePath(sessionId);
if (!storeDbPath) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
const { default: Database } = await import('better-sqlite3');
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const row = db.prepare(`SELECT value FROM meta WHERE key='0' LIMIT 1;`).get() as {
value?: Buffer | string;
} | undefined;
const metadataText = Buffer.isBuffer(row?.value)
? row.value.toString('utf8')
: typeof row?.value === 'string' && row.value.trim()
? Buffer.from(row.value.trim(), 'hex').toString('utf8')
: '';
if (!metadataText) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
const metadata = JSON.parse(metadataText) as { lastUsedModel?: string };
if (typeof metadata.lastUsedModel === 'string' && metadata.lastUsedModel.trim()) {
return {
model: metadata.lastUsedModel.trim(),
};
}
} finally {
db.close();
}
} catch {
// Fall through to the provider default when Cursor metadata cannot be read.
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
}

View File

@@ -4,7 +4,12 @@ import path from 'node:path';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import {
createNormalizedMessage,
generateMessageId,
readObjectRecord,
sanitizeLeafDirectoryName,
} from '@/shared/utils.js';
const PROVIDER = 'cursor';
@@ -186,24 +191,6 @@ function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown
return normalized;
}
function sanitizeCursorSessionId(sessionId: string): string {
const normalized = sessionId.trim();
if (!normalized) {
throw new Error('Cursor session id is required.');
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid cursor session id "${sessionId}".`);
}
return normalized;
}
export class CursorSessionsProvider implements IProviderSessions {
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
@@ -214,7 +201,7 @@ export class CursorSessionsProvider implements IProviderSessions {
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const safeSessionId = sanitizeCursorSessionId(sessionId);
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
const resolvedBaseChatsPath = path.resolve(baseChatsPath);

View File

@@ -1,5 +1,6 @@
import type { IProviderModels } from '@/shared/interfaces.js';
import type { ProviderModelsDefinition } from '@/shared/types.js';
import type { ProviderCurrentActiveModel, ProviderModelsDefinition } from '@/shared/types.js';
import { buildDefaultProviderCurrentActiveModel } from '@/shared/utils.js';
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
@@ -20,4 +21,8 @@ export class GeminiProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
return GEMINI_FALLBACK_MODELS;
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
}
}

View File

@@ -1,9 +1,20 @@
import Database from 'better-sqlite3';
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn';
import type { IProviderModels } from '@/shared/interfaces.js';
import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js';
import type {
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
getOpenCodeDatabasePath,
readObjectRecord,
readOptionalString,
} from '@/shared/utils.js';
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
@@ -66,6 +77,32 @@ const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition
};
};
const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
if (typeof rawModel === 'string') {
const trimmed = rawModel.trim();
if (!trimmed) {
return null;
}
try {
return parseOpenCodeSessionModelValue(JSON.parse(trimmed));
} catch {
return trimmed;
}
}
const record = readObjectRecord(rawModel);
if (!record) {
return null;
}
return readOptionalString(record.id)
?? readOptionalString(record.model)
?? readOptionalString(record.name)
?? readOptionalString(record.value)
?? null;
};
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
const openCodeProcess = spawnFunction('opencode', ['models'], {
cwd: process.cwd(),
@@ -136,4 +173,51 @@ export class OpenCodeProviderModels implements IProviderModels {
return OPENCODE_FALLBACK_MODELS;
}
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
if (!sessionId?.trim()) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
try {
const dbPath = getOpenCodeDatabasePath();
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db.prepare(`
SELECT
s.id AS sessionId,
s.model AS model,
s.agent AS agent,
s.directory AS directory,
s.time_updated AS timeUpdated,
s.time_created AS timeCreated
FROM session s
WHERE s.id = ?
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC
LIMIT 1
`).get(sessionId) as {
sessionId?: string;
model?: unknown;
agent?: string | null;
directory?: string | null;
timeUpdated?: number | null;
timeCreated?: number | null;
} | undefined;
const model = parseOpenCodeSessionModelValue(row?.model);
if (model) {
return {
model,
};
}
} finally {
db.close();
}
} catch {
// Fall through to the provider default when OpenCode session lookup fails.
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
}