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
104 changes: 85 additions & 19 deletions src-tauri/src/api_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1802,22 +1802,56 @@ struct AgentRuntimeConfig {

fn project_llm_config(parsed: &Value, project_id: &str) -> Option<agent::provider::LlmConfig> {
let global = parsed.get("llmConfig").cloned();
let Some(project) = parsed
let project = parsed
.get("projectLlmOverrides")
.and_then(|value| value.get(project_id))
else {
return global.and_then(|value| serde_json::from_value(value).ok());
};
if project.get("enabled").and_then(Value::as_bool) != Some(true) {
return global.and_then(|value| serde_json::from_value(value).ok());
.and_then(|value| value.get(project_id));
if let Some(project) = project {
if project.get("enabled").and_then(Value::as_bool) == Some(true) {
let profile = project.get("profile")?.clone();
let preset_id = project.get("presetId").and_then(Value::as_str)?;
let model_override = project.get("model").and_then(Value::as_str);
return resolve_routed_llm_config(parsed, profile, preset_id, model_override)
.or_else(|| global.and_then(|value| serde_json::from_value(value).ok()));
}
}

let global_config = global
.clone()
.and_then(|value| serde_json::from_value::<agent::provider::LlmConfig>(value).ok());
if !global_config.as_ref().is_some_and(is_llm_enabled) {
return global_config;
}
let chat_config = parsed
.get("taskModelRouting")
.and_then(|routing| {
Some((
routing.get("chatProfile")?.clone(),
routing.get("chatPresetId")?.as_str()?,
))
})
.and_then(|(profile, preset_id)| {
resolve_routed_llm_config(parsed, profile, preset_id, None)
});
chat_config.or(global_config)
}

fn is_llm_enabled(config: &agent::provider::LlmConfig) -> bool {
matches!(
config.provider.as_str(),
"ollama" | "custom" | "claude-code" | "codex-cli"
) || !config.api_key.trim().is_empty()
}

fn resolve_routed_llm_config(
parsed: &Value,
mut profile: Value,
preset_id: &str,
model_override: Option<&str>,
) -> Option<agent::provider::LlmConfig> {
// The frontend persists a resolved, non-secret profile. Merge the current
// provider override here so credential rotation and endpoint edits apply to
// native API calls without duplicating API keys in every project record.
let mut profile = project.get("profile")?.clone();
let profile_object = profile.as_object_mut()?;
let preset_id = project.get("presetId").and_then(Value::as_str)?;
let provider = parsed
.get("providerConfigs")
.and_then(|value| value.get(preset_id))
Expand All @@ -1835,10 +1869,10 @@ fn project_llm_config(parsed: &Value, project_id: &str) -> Option<agent::provide
})
.unwrap_or(false);
// Match resolveProjectLlmConfig in src/lib/llm-task-routing.ts: deleting a
// custom preset must make every project that referenced it fall back to
// the global config, including projects that are not currently open.
// custom preset must make every route that referenced it fall back to the
// global config, including projects that are not currently open.
if preset_id.starts_with("custom-") && !custom_preset_exists {
return global.and_then(|value| serde_json::from_value(value).ok());
return None;
}
if let Some(provider) = provider {
for key in [
Expand All @@ -1852,13 +1886,7 @@ fn project_llm_config(parsed: &Value, project_id: &str) -> Option<agent::provide
profile_object.insert(key.to_string(), value.clone());
}
}
if project
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("")
.is_empty()
{
if model_override.map(str::trim).unwrap_or("").is_empty() {
if let Some(value) = provider.get("model") {
profile_object.insert("model".to_string(), value.clone());
}
Expand Down Expand Up @@ -2821,6 +2849,44 @@ mod tests {
assert_eq!(config.custom_endpoint, "https://new.example/v1");
}

#[test]
fn project_llm_config_uses_chat_route_without_project_override() {
let state = json!({
"llmConfig": { "provider": "openai", "apiKey": "global", "model": "gpt-global", "ollamaUrl": "", "customEndpoint": "", "maxContextSize": 1000 },
"providerConfigs": {
"deepseek": { "apiKey": "chat-secret", "model": "chat-model", "baseUrl": "https://new.example/v1", "apiMode": "chat_completions" }
},
"taskModelRouting": {
"chatPresetId": "deepseek",
"chatProfile": { "provider": "custom", "model": "old-model", "ollamaUrl": "", "customEndpoint": "https://old.example/v1", "maxContextSize": 64000, "apiMode": "chat_completions" }
}
});

let config = project_llm_config(&state, "project-without-override").expect("chat config");
assert_eq!(config.provider, "custom");
assert_eq!(config.api_key, "chat-secret");
assert_eq!(config.model, "chat-model");
assert_eq!(config.custom_endpoint, "https://new.example/v1");
}

#[test]
fn project_llm_config_does_not_use_chat_route_when_global_provider_is_disabled() {
let state = json!({
"llmConfig": { "provider": "openai", "apiKey": "", "model": "", "ollamaUrl": "", "customEndpoint": "", "maxContextSize": 1000 },
"providerConfigs": {
"deepseek": { "apiKey": "saved-secret", "model": "chat-model", "baseUrl": "https://new.example/v1", "apiMode": "chat_completions" }
},
"taskModelRouting": {
"chatPresetId": "deepseek",
"chatProfile": { "provider": "custom", "model": "chat-model", "ollamaUrl": "", "customEndpoint": "https://new.example/v1", "maxContextSize": 64000, "apiMode": "chat_completions" }
}
});

let config = project_llm_config(&state, "project-without-override").expect("global config");
assert_eq!(config.provider, "openai");
assert!(config.api_key.is_empty());
}

#[test]
fn project_llm_config_falls_back_for_disabled_or_legacy_override() {
let state = json!({
Expand Down
37 changes: 36 additions & 1 deletion src-tauri/src/commands/project_maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,27 @@ mod tests {
let _ = fs::remove_dir_all(target);
let _ = fs::remove_file(archive);
}

#[test]
fn export_rejects_destination_that_resolves_inside_project() {
let root = temp("export-inside-project");
fs::create_dir_all(root.join("wiki")).unwrap();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(root.join("wiki/index.md"), "# Index").unwrap();
let destination = root.join("nested/../wiki/index.md");

let result = export_project_archive_inner(
root.to_string_lossy().into_owned(),
destination.to_string_lossy().into_owned(),
);

assert!(result.is_err());
assert_eq!(
fs::read_to_string(root.join("wiki/index.md")).unwrap(),
"# Index"
);
let _ = fs::remove_dir_all(root);
}
}

#[tauri::command]
Expand All @@ -98,7 +119,21 @@ fn export_project_archive_inner(project_path: String, destination: String) -> Re
.canonicalize()
.map_err(|e| e.to_string())?;
let output = PathBuf::from(destination);
if output.starts_with(&root) {
let parent = output
.parent()
.ok_or_else(|| "Export destination must have a parent directory".to_string())?;
let filename = output
.file_name()
.ok_or_else(|| "Export destination must be a file path".to_string())?;
let resolved_output = if output.exists() {
output.canonicalize().map_err(|e| e.to_string())?
} else {
parent
.canonicalize()
.map_err(|e| e.to_string())?
.join(filename)
};
if resolved_output.starts_with(&root) {
return Err("Export destination must be outside the project directory".into());
}
let file = File::create(&output).map_err(|e| e.to_string())?;
Expand Down
47 changes: 40 additions & 7 deletions src/components/settings/sections/llm-provider-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,37 @@ export function LlmProviderSection() {
}

function updateOverride(id: string, patch: ProviderOverride) {
const currentConfigs = useWikiStore.getState().providerConfigs
const state = useWikiStore.getState()
const currentConfigs = state.providerConfigs
const merged: ProviderOverride = { ...(currentConfigs[id] ?? {}), ...patch }
const next = { ...currentConfigs, [id]: merged }
setProviderConfigs(next)
persist(next, activePresetId).catch(() => {})
// If this preset is active, refresh the resolved LlmConfig live.
if (id === activePresetId) {
const usesProjectOverride = projectLlmOverride.enabled && projectLlmOverride.presetId === id
if (id === activePresetId || usesProjectOverride) {
const preset = findLlmPreset(id, customLlmPresets)
if (preset) {
const resolved = resolveConfig(preset, merged, globalLlmConfig)
setGlobalLlmConfig(resolved)
setLlmConfig(resolveProjectLlmConfig(resolved, next, projectLlmOverride, customLlmPresets))
const global = id === activePresetId
? resolveConfig(preset, merged, globalLlmConfig)
: globalLlmConfig
if (id === activePresetId) setGlobalLlmConfig(global)
const resolved = resolveProjectLlmConfig(global, next, projectLlmOverride, customLlmPresets)
setLlmConfig(resolved)
if (project && usesProjectOverride) {
const updatedOverride = { ...projectLlmOverride, profile: projectLlmProfile(resolved) }
setProjectLlmOverride(updatedOverride)
saveProjectLlmOverride(project.id, updatedOverride).catch(() => {})
}
if (state.taskModelRouting.chatPresetId === id) {
const nextRouting = {
...state.taskModelRouting,
chatProfile: projectLlmProfile(resolveConfig(preset, merged, globalLlmConfig)),
}
setTaskModelRouting(nextRouting)
import("@/lib/project-store")
.then(({ saveTaskModelRouting }) => saveTaskModelRouting(nextRouting))
.catch(() => {})
}
}
}
setSavedId(id)
Expand All @@ -95,10 +114,22 @@ export function LlmProviderSection() {
}

async function updateTaskRouting(task: "chat" | "ingest", value: string) {
const state = useWikiStore.getState()
const presetId = value || null
const next = {
...taskModelRouting,
...state.taskModelRouting,
[task === "chat" ? "chatPresetId" : "ingestPresetId"]: value || null,
}
if (task === "chat") {
if (!presetId) {
next.chatProfile = undefined
} else {
const preset = findLlmPreset(presetId, state.customLlmPresets)
next.chatProfile = preset
? projectLlmProfile(resolveConfig(preset, state.providerConfigs[presetId], state.globalLlmConfig))
: undefined
}
}
setTaskModelRouting(next)
const { saveTaskModelRouting } = await import("@/lib/project-store")
await saveTaskModelRouting(next)
Expand Down Expand Up @@ -136,7 +167,9 @@ export function LlmProviderSection() {
const nextPresets = state.customLlmPresets.filter((preset) => preset.id !== id)
const { [id]: _removed, ...nextConfigs } = state.providerConfigs
const nextRouting = {
...state.taskModelRouting,
chatPresetId: state.taskModelRouting.chatPresetId === id ? null : state.taskModelRouting.chatPresetId,
chatProfile: state.taskModelRouting.chatPresetId === id ? undefined : state.taskModelRouting.chatProfile,
ingestPresetId: state.taskModelRouting.ingestPresetId === id ? null : state.taskModelRouting.ingestPresetId,
}
setCustomLlmPresets(nextPresets)
Expand Down
3 changes: 2 additions & 1 deletion src/lib/clip-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useWikiStore } from "@/stores/wiki-store"
import { enqueueIngest } from "./ingest-queue"
import { hasUsableLlm } from "@/lib/has-usable-llm"
import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh"
import { getTaskLlmConfig } from "@/lib/llm-task-routing"

const POLL_INTERVAL = 3000 // Check every 3 seconds
let intervalId: ReturnType<typeof setInterval> | null = null
Expand Down Expand Up @@ -36,7 +37,7 @@ export function startClipWatcher() {
// a UI refresh. Same path used by file imports from sources-view.
// Pass the project's stable UUID — the queue looks up the
// current filesystem path from the registry at run time.
if (hasUsableLlm(store.llmConfig)) {
if (hasUsableLlm(getTaskLlmConfig("ingest", store.llmConfig))) {
enqueueIngest(project.id, clipFilePath).catch((err) => {
console.error("Failed to enqueue web clip:", err)
})
Expand Down
11 changes: 11 additions & 0 deletions src/lib/llm-task-routing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"
import { resolveProjectLlmConfig, resolveTaskLlmConfig } from "./llm-task-routing"
import { disabledLlmConfig } from "@/components/settings/preset-resolver"
import type { LlmConfig } from "@/stores/wiki-store"

const fallback: LlmConfig = {
Expand Down Expand Up @@ -45,6 +46,16 @@ describe("resolveTaskLlmConfig", () => {
})).toBe(fallback)
})

it("does not resolve a saved task route after the global provider is disabled", () => {
const disabled = disabledLlmConfig(fallback)
expect(resolveTaskLlmConfig("ingest", disabled, {
anthropic: { apiKey: "saved-key", model: "claude-sonnet-4-6" },
}, {
chatPresetId: null,
ingestPresetId: "anthropic",
})).toBe(disabled)
})

it("routes tasks through a user-defined custom provider", () => {
const custom = [{ id: "custom-team", label: "Team Gateway" }]
expect(resolveTaskLlmConfig(
Expand Down
2 changes: 2 additions & 0 deletions src/lib/llm-task-routing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { findLlmPreset } from "@/components/settings/llm-presets"
import { resolveConfig } from "@/components/settings/preset-resolver"
import { hasUsableLlm } from "@/lib/has-usable-llm"
import type {
LlmConfig,
ProjectLlmOverride,
Expand Down Expand Up @@ -50,6 +51,7 @@ export function resolveTaskLlmConfig(
customPresets: CustomLlmPreset[] = [],
): LlmConfig {
if (projectOverride?.enabled) return fallback
if (!hasUsableLlm(fallback)) return fallback
const presetId = task === "chat" ? routing.chatPresetId : routing.ingestPresetId
if (!presetId) return fallback
const preset = findLlmPreset(presetId, customPresets)
Expand Down
10 changes: 8 additions & 2 deletions src/lib/project-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const PROJECT_LLM_OVERRIDES_KEY = "projectLlmOverrides"
const CUSTOM_LLM_PRESETS_KEY = "customLlmPresets"
let projectLlmOverrideWrite = Promise.resolve()
let customLlmPresetWrite = Promise.resolve()
let taskModelRoutingWrite = Promise.resolve()

export async function saveLlmConfig(config: LlmConfig): Promise<void> {
const store = await getStore()
Expand Down Expand Up @@ -113,8 +114,12 @@ export async function loadActivePresetId(): Promise<string | null> {
}

export async function saveTaskModelRouting(config: TaskModelRoutingConfig): Promise<void> {
const store = await getStore()
await store.set(TASK_MODEL_ROUTING_KEY, config)
const write = taskModelRoutingWrite.then(async () => {
const store = await getStore()
await store.set(TASK_MODEL_ROUTING_KEY, config)
})
taskModelRoutingWrite = write.catch(() => {})
await write
}

export async function loadTaskModelRouting(): Promise<TaskModelRoutingConfig | null> {
Expand All @@ -123,6 +128,7 @@ export async function loadTaskModelRouting(): Promise<TaskModelRoutingConfig | n
if (!saved) return null
return {
chatPresetId: typeof saved.chatPresetId === "string" ? saved.chatPresetId : null,
chatProfile: saved.chatProfile,
ingestPresetId: typeof saved.ingestPresetId === "string" ? saved.ingestPresetId : null,
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/stores/wiki-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ export interface CustomLlmPreset {
export interface TaskModelRoutingConfig {
/** Null keeps chat on the globally active provider preset. */
chatPresetId: string | null
/** Resolved non-secret profile for local API chat callers. */
chatProfile?: Omit<LlmConfig, "apiKey">
/** Null keeps ingest on the globally active provider preset. */
ingestPresetId: string | null
}
Expand Down
Loading