Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lat.md/model-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The in-chat (bottom) model picker selects a model for the **current conversation

The override is held in renderer state on each `<Chat>` run ([[src/renderer/src/screens/Chat/Chat.tsx]]), persisted by session id, and sent with every message; it is cleared when the conversation is cleared/reset and is absent on a fresh chat, so new conversations start on the global default. This is distinct from the persisted [[model-context]] default that non-chat surfaces read.

Pre-send readiness uses the same active override. [[src/renderer/src/screens/Chat/Chat.tsx#Chat]] passes the current picker `{provider, model, baseUrl}` into [[src/main/validation.ts#validateChatReadiness]], so a session-scoped pick does not keep showing "No model selected" just because the global `config.yaml` default is empty.

## Full identity, not just the model name

The override is a `SessionModelOverride` (`{provider, model, baseUrl}`), not a bare model string — because switching across providers must change routing, not only the `model` field.
Expand Down
10 changes: 7 additions & 3 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,9 +850,13 @@ export function registerIpcHandlers(context: IpcContext): void {
// Pre-send chat readiness — answers "if Send is clicked right now,
// will it work?". Fail-open semantics: any uncertain state returns
// `ok: true`, so the renderer never false-blocks a Send.
ipcMain.handle("validate-chat-readiness", (_event, profile?: string) => {
return validateChatReadiness(profile);
});
type ChatReadinessOverride = Parameters<typeof validateChatReadiness>[1];
ipcMain.handle(
"validate-chat-readiness",
(_event, profile?: string, override?: ChatReadinessOverride) => {
return validateChatReadiness(profile, override);
},
);

// Config-health audit + per-issue auto-fix. The renderer renders a
// dismissible banner above the chat input and a full report in the
Expand Down
22 changes: 17 additions & 5 deletions src/main/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface ChatReadiness {
expectedEnvKey?: string;
}

export interface ChatReadinessModelConfig {
provider?: string;
model?: string;
baseUrl?: string;
}

const OK: ChatReadiness = { ok: true };

// Provider ids that authenticate ONLY via interactive OAuth login —
Expand Down Expand Up @@ -79,14 +85,20 @@ const NO_KEY_PROVIDERS = new Set(["auto"]);
* Synchronous readiness check against the desktop's own config —
* no network calls. Fast (single readEnv + getModelConfig).
*
* `profile` defaults to the active profile.
* `profile` defaults to the active profile. `override` is the chat-local model
* picker selection; when present it is the model that will actually be sent.
*/
export function validateChatReadiness(profile?: string): ChatReadiness {
export function validateChatReadiness(
profile?: string,
override?: ChatReadinessModelConfig,
): ChatReadiness {
try {
const mc = getModelConfig(profile);
const provider = (mc.provider || "").trim().toLowerCase();
const model = (mc.model || "").trim();
const baseUrl = (mc.baseUrl || "").trim();
const provider = (override?.provider ?? mc.provider ?? "")
.trim()
.toLowerCase();
const model = (override?.model ?? mc.model ?? "").trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty Override Masks Default

When the readiness override object contains model: "", this line keeps the empty value instead of falling back to the persisted model. The send path falls back for an empty override model, so this can show NO_ACTIVE_MODEL and block a send that would otherwise use the configured default model.

Suggested change
const model = (override?.model ?? mc.model ?? "").trim();
const model = (override?.model || mc.model || "").trim();

const baseUrl = (override?.baseUrl ?? mc.baseUrl ?? "").trim();

// Provider="auto" lets hermes-agent pick a model at runtime based
// on whatever keys are present in .env. No key-presence check
Expand Down
5 changes: 4 additions & 1 deletion src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ interface HermesAPI {
// Configuration (profile-aware)
getEnv: (profile?: string) => Promise<Record<string, string>>;
setEnv: (key: string, value: string, profile?: string) => Promise<boolean>;
validateChatReadiness: (profile?: string) => Promise<{
validateChatReadiness: (
profile?: string,
override?: { provider?: string; model?: string; baseUrl?: string },
) => Promise<{
ok: boolean;
code?:
| "NO_ACTIVE_MODEL"
Expand Down
3 changes: 2 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ const hermesAPI = {

validateChatReadiness: (
profile?: string,
override?: { provider?: string; model?: string; baseUrl?: string },
): Promise<{
ok: boolean;
code?:
Expand All @@ -245,7 +246,7 @@ const hermesAPI = {
message?: string;
fixLocation?: "providers" | "models" | "gateway" | "setup";
expectedEnvKey?: string;
}> => ipcRenderer.invoke("validate-chat-readiness", profile),
}> => ipcRenderer.invoke("validate-chat-readiness", profile, override),

getConfigHealth: (profile?: string): Promise<unknown> =>
ipcRenderer.invoke("get-config-health", profile),
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,11 @@ function Chat({
let cancelled = false;
(async (): Promise<void> => {
try {
const r = await window.hermesAPI.validateChatReadiness(profile);
const r = await window.hermesAPI.validateChatReadiness(profile, {
provider: chatCurrentProvider,
model: chatCurrentModel,
baseUrl: chatCurrentBaseUrl,
});
if (!cancelled) setReadiness(r);
} catch {
// Fail open on IPC error — never block Send on validation failure
Expand Down
22 changes: 22 additions & 0 deletions tests/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,28 @@ describe("validateChatReadiness", () => {
expect(validateChatReadiness()).toEqual({ ok: true });
});

it("uses the chat picker model override instead of blocking on an empty persisted model", async () => {
writeConfig(
[
"model:",
" provider: alibaba",
" default: ''",
" base_url: https://dashscope.aliyuncs.com/compatible-mode/v1",
"",
].join("\n"),
);
writeEnv("DASHSCOPE_API_KEY=sk-dashscope-test\n");
const { validateChatReadiness } = await freshValidation(TEST_DIR);

expect(
validateChatReadiness(undefined, {
provider: "alibaba",
model: "qwen3.7-plus",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
}),
).toEqual({ ok: true });
});

it("treats whitespace-only key value as missing", async () => {
writeConfig(
[
Expand Down
Loading