mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 14:55:34 +08:00
feat: support session-scoped model overrides
Model selection was acting like a provider-level preference. That made resumed sessions drift back to a default or request-time model. Users expect /models changes made inside a conversation to affect that session. Store explicit session choices in app-owned ~/.cloudcli state. This avoids editing provider transcripts or native provider config. Resolve the effective model before launching each provider runtime. Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices. Expose a backend active-model change endpoint for existing sessions. The models modal can now distinguish default changes from session overrides. It also shows when a selected model will apply on the next response. For Claude, stop probing active model state by resuming with a dummy prompt. Read the indexed JSONL transcript from the end instead. This preserves provider history while honoring /model stdout or model fields. Add service tests for adapter delegation and resume-model precedence. The tests keep cache state, override state, and requested fallback separate.
This commit is contained in:
@@ -7,9 +7,11 @@ import type {
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderAuthStatus,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderMcpServer,
|
||||
ProviderSessionActiveModelChange,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -55,6 +57,19 @@ export interface IProviderModels {
|
||||
* no active model can be resolved.
|
||||
*/
|
||||
getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel>;
|
||||
|
||||
/**
|
||||
* Persists a session-scoped model override that the next resumed turn should
|
||||
* honor for this provider.
|
||||
*
|
||||
* This does not require the provider to mutate an already running remote
|
||||
* session in-place. Instead, adapters store the user's explicit model choice
|
||||
* so the backend resume path can add the correct provider-native model option
|
||||
* on the next CLI/SDK invocation for the same session.
|
||||
*/
|
||||
changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
||||
@@ -123,6 +123,37 @@ export type ProviderCurrentActiveModel = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input payload used when one session needs to use a different model on its
|
||||
* next resumed turn.
|
||||
*
|
||||
* This is a backend-owned session override, not a claim that the provider has
|
||||
* already switched the currently running session in-place. Provider adapters
|
||||
* persist this request so the next CLI/SDK resume can inject the chosen model
|
||||
* using the provider-specific mechanism supported by that runtime.
|
||||
*/
|
||||
export type ProviderChangeActiveModelInput = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-neutral session model-change state.
|
||||
*
|
||||
* `supported` indicates whether the provider adapter supports the app's
|
||||
* session-scoped resume override flow. `changed` is the persisted boolean the
|
||||
* resume layer checks before forcing a model on the next resumed turn. When
|
||||
* `changed` is `false`, `model` is `null` and the runtime should use the
|
||||
* normal request/default model selection path.
|
||||
*/
|
||||
export type ProviderSessionActiveModelChange = {
|
||||
provider: LLMProvider;
|
||||
sessionId: string;
|
||||
supported: boolean;
|
||||
changed: boolean;
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message/event variants emitted by provider adapters and normalized transports.
|
||||
*
|
||||
|
||||
@@ -22,9 +22,12 @@ import type {
|
||||
AnyRecord,
|
||||
ApiSuccessShape,
|
||||
AppErrorOptions,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
ProviderSkillSource,
|
||||
WorkspacePathValidationResult,
|
||||
} from '@/shared/types.js';
|
||||
@@ -434,6 +437,213 @@ export function buildDefaultProviderCurrentActiveModel(
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SESSION MODEL CHANGE UTILITIES ------------
|
||||
type ProviderSessionActiveModelChangeCacheEntry = ProviderSessionActiveModelChange & {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ProviderSessionActiveModelChangeCacheFile = {
|
||||
version: number;
|
||||
entries: Record<string, ProviderSessionActiveModelChangeCacheEntry>;
|
||||
};
|
||||
|
||||
const PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Resolves the backend-owned cache file used for session-scoped resume model
|
||||
* overrides.
|
||||
*
|
||||
* The file lives under `~/.cloudcli` because these overrides are an application
|
||||
* concern rather than a provider-native config file. Providers, routes, and
|
||||
* runtime command launchers should all use this helper instead of re-creating
|
||||
* the path so the storage location stays consistent.
|
||||
*/
|
||||
export function getProviderSessionActiveModelChangesPath(): string {
|
||||
return path.join(os.homedir(), '.cloudcli', 'provider-session-active-model-changes.json');
|
||||
}
|
||||
|
||||
const buildProviderSessionActiveModelChangeKey = (
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
): string => `${provider}:${sessionId}`;
|
||||
|
||||
const isProviderSessionActiveModelChangeCacheEntry = (
|
||||
value: unknown,
|
||||
): value is ProviderSessionActiveModelChangeCacheEntry => {
|
||||
const record = readObjectRecord(value);
|
||||
return Boolean(
|
||||
record
|
||||
&& typeof record.provider === 'string'
|
||||
&& typeof record.sessionId === 'string'
|
||||
&& typeof record.supported === 'boolean'
|
||||
&& typeof record.changed === 'boolean'
|
||||
&& (typeof record.model === 'string' || record.model === null)
|
||||
&& typeof record.updatedAt === 'string',
|
||||
);
|
||||
};
|
||||
|
||||
const readProviderSessionActiveModelChangeCacheFile = async (
|
||||
filePath: string,
|
||||
): Promise<ProviderSessionActiveModelChangeCacheFile> => {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = readObjectRecord(JSON.parse(raw));
|
||||
if (
|
||||
!parsed
|
||||
|| parsed.version !== PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION
|
||||
|| !readObjectRecord(parsed.entries)
|
||||
) {
|
||||
return {
|
||||
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
|
||||
entries: {},
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderSessionActiveModelChangeCacheEntry] =>
|
||||
isProviderSessionActiveModelChangeCacheEntry(entry[1]),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
|
||||
entries,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
|
||||
entries: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const writeProviderSessionActiveModelChangeCacheFile = async (
|
||||
filePath: string,
|
||||
payload: ProviderSessionActiveModelChangeCacheFile,
|
||||
): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const buildUnsupportedProviderSessionActiveModelChange = (
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
): ProviderSessionActiveModelChange => ({
|
||||
provider,
|
||||
sessionId,
|
||||
supported: false,
|
||||
changed: false,
|
||||
model: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Reads the persisted session model-change state for one provider session.
|
||||
*
|
||||
* Runtime resume paths use this to decide whether they should inject a
|
||||
* provider-specific model argument/thread option for the next resumed turn.
|
||||
* Missing cache entries are normalized to `{ changed: false }` so callers can
|
||||
* treat absence as "use the ordinary model selection flow".
|
||||
*/
|
||||
export async function readProviderSessionActiveModelChange(
|
||||
provider: LLMProvider,
|
||||
sessionId: string,
|
||||
options: {
|
||||
filePath?: string;
|
||||
supported?: boolean;
|
||||
} = {},
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
|
||||
}
|
||||
|
||||
const supported = options.supported ?? true;
|
||||
if (!supported) {
|
||||
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
|
||||
}
|
||||
|
||||
const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
|
||||
const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
|
||||
const cacheEntry = cacheFile.entries[
|
||||
buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)
|
||||
];
|
||||
|
||||
if (!cacheEntry || !cacheEntry.changed || !cacheEntry.model?.trim()) {
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: cacheEntry.model.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a session model-change request for one provider.
|
||||
*
|
||||
* Provider adapters call this when the frontend explicitly selects a different
|
||||
* model for an existing session. The stored `changed: true` flag is the single
|
||||
* source of truth used later by resume paths to decide whether they should add
|
||||
* a provider-native model override on the next invocation.
|
||||
*/
|
||||
export async function writeProviderSessionActiveModelChange(
|
||||
provider: LLMProvider,
|
||||
input: ProviderChangeActiveModelInput,
|
||||
options: {
|
||||
filePath?: string;
|
||||
supported?: boolean;
|
||||
} = {},
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
const normalizedSessionId = input.sessionId.trim();
|
||||
const normalizedModel = input.model.trim();
|
||||
const supported = options.supported ?? true;
|
||||
|
||||
if (!supported) {
|
||||
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
|
||||
}
|
||||
|
||||
if (!normalizedSessionId || !normalizedModel) {
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
|
||||
const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
|
||||
cacheFile.entries[buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)] = {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: normalizedModel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeProviderSessionActiveModelChangeCacheFile(filePath, cacheFile);
|
||||
|
||||
return {
|
||||
provider,
|
||||
sessionId: normalizedSessionId,
|
||||
supported: true,
|
||||
changed: true,
|
||||
model: normalizedModel,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user