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
76 changes: 73 additions & 3 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ export type ProviderConfig = {
enabled: boolean
}

export type ProviderInfo = {
models: ModelEntry[]
baseUrl: string
source: "personal" | "workspace"
enabled: boolean
hasKey: boolean
missingVar?: string
}

export type ProvidersResponse = {
providers: Record<string, ProviderInfo>
default: string
}

export type RemoteSpec = {
/** clone URL; empty string or omitted = local-only, don't clone */
git?: string
Expand Down Expand Up @@ -645,6 +659,60 @@ export function describeApiKeyRef(
return { kind: "mixed", exists: false }
}

export async function getProvidersResponse(user: string): Promise<ProvidersResponse> {
const wCfg = await loadConfig()
const providers: Record<string, ProviderInfo> = {}
if (wCfg.providers) {
for (const [name, p] of Object.entries(wCfg.providers)) {
const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0
providers[name] = {
models: p.models,
baseUrl: p.baseUrl,
source: "workspace",
enabled: hasKey ? p.enabled : false,
hasKey,
}
}
}

let active = wCfg.default ?? ""
try {
const pCfg = await loadPersonalConfig(user)
const rawDisk = await readPersonalDiskRaw(user).catch(() => null)
for (const [name, p] of Object.entries(pCfg.providers)) {
const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0
if (hasKey) {
providers[name] = {
models: p.models,
baseUrl: p.baseUrl,
source: "personal",
enabled: p.enabled !== false,
hasKey,
}
} else if (!providers[name]) {
const rawApiKey = (rawDisk?.providers as any)?.[name]?.apiKey
const ref = describeApiKeyRef(rawApiKey, user)
providers[name] = {
models: p.models,
baseUrl: p.baseUrl,
source: "personal",
enabled: false,
hasKey: false,
...(ref.kind === "var" && ref.varName && !ref.exists ? { missingVar: ref.varName } : {}),
}
}
}
active = pCfg.default ||
Object.keys(pCfg.providers).find(n => {
const p = pCfg.providers[n]
return typeof p.apiKey === "string" && p.apiKey.length > 0
}) ||
active
} catch {}

return { providers, default: active }
}

/**
* Apply a structural patch to personal/<user>/.loopat/config.json. Accepts
* partial fields from `PersonalConfigDisk`; only fields present on the
Expand Down Expand Up @@ -844,20 +912,22 @@ export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promis
const existing = await loadConfig()
const merged: WorkspaceConfig = { ...existing }
if (cfg.providers !== undefined) {
merged.providers = merged.providers ?? {}
const existingProviders = existing.providers ?? {}
const nextProviders: Record<string, ProviderConfig> = {}
for (const [name, p] of Object.entries(cfg.providers)) {
const existingProv = merged.providers[name]
const existingProv = existingProviders[name]
const incoming = p as any
const models: ModelEntry[] = incoming.models?.length > 0
? incoming.models.map((m: any) => normalizeModelEntry(m as ModelEntryDisk))
: existingProv?.models ?? []
merged.providers[name] = {
nextProviders[name] = {
models,
baseUrl: incoming.baseUrl ?? existingProv?.baseUrl ?? "",
apiKey: incoming.apiKey || existingProv?.apiKey || "",
enabled: incoming.enabled !== undefined ? incoming.enabled : (existingProv?.enabled ?? true),
} as any
}
merged.providers = nextProviders
}
if (cfg.default !== undefined) merged.default = cfg.default
if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge
Expand Down
28 changes: 4 additions & 24 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
personalReposDir,
loopsDir,
} from "./paths"
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, getProvidersResponse, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
import { queryUserTokenUsage, queryWorkspaceTokenUsage, queryDailyTokenUsage, queryLoopTokenUsage } from "./usage"
import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
Expand Down Expand Up @@ -264,31 +264,11 @@ app.get("/api/serve/check-port", requireAuth, async (c) => {
// ── providers (auth required) ──
// Merges personal + workspace configs. Personal providers take precedence
// (they carry per-user apiKeys via secrets/). Source field indicates origin.
// Active default mirrors runtime resolution: a configured personal provider can
// become active even when workspace has a default.
app.get("/api/providers", requireAuth, async (c) => {
const wCfg = await loadConfig()
const providers: Record<string, { models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean }> = {}
if (wCfg.providers) {
for (const [name, p] of Object.entries(wCfg.providers)) {
const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0
providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "workspace", enabled: hasKey ? p.enabled : false, hasKey }
}
}
// Overlay personal providers (they take precedence)
let active = wCfg.default ?? ""
const userId = c.get("userId") as string
try {
const pCfg = await loadPersonalConfig(userId)
for (const [name, p] of Object.entries(pCfg.providers)) {
const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0
// Only overlay if the user actually configured this provider (has a key).
// Template/preset providers without a key should not shadow workspace config.
if (hasKey) {
providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "personal", enabled: p.enabled !== false, hasKey }
}
}
active = pCfg.default || active
} catch {}
return c.json({ providers, default: active })
return c.json(await getProvidersResponse(userId))
})

// Test a provider + model connection by making a minimal Messages API call.
Expand Down
157 changes: 157 additions & 0 deletions server/test/provider-settings-fixes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Regression coverage for provider settings correctness fixes.
*/
import { test, expect, describe, afterAll, beforeEach } from "bun:test"
import { mkdir, rm, writeFile } from "node:fs/promises"
import { join } from "node:path"

const ownsHome = process.env.LOOPAT_HOME === undefined
if (ownsHome) process.env.LOOPAT_HOME = `/tmp/loopat-provider-settings-${process.pid}`

const HOME = process.env.LOOPAT_HOME!
await mkdir(HOME, { recursive: true })
if (ownsHome) {
await rm(HOME, { recursive: true, force: true })
await mkdir(HOME, { recursive: true })
await writeFile(join(HOME, "config.json"), JSON.stringify({
knowledge: { git: "" },
notes: { git: "" },
repos: [],
providers: {},
}))
}

const {
loadConfig,
saveWorkspaceConfig,
getProvidersResponse,
clearPersonalCache,
} = await import("../src/config")
const {
personalLoopatDir,
personalLoopatConfigPath,
personalVaultEnvsDir,
personalVaultEnvPath,
} = await import("../src/paths")

const USER = "providerfix"

async function writePersonalProviders(providers: Record<string, any>): Promise<void> {
await mkdir(personalLoopatDir(USER), { recursive: true })
await mkdir(personalVaultEnvsDir(USER, "default"), { recursive: true })
await writeFile(personalLoopatConfigPath(USER), JSON.stringify({ providers }, null, 2) + "\n")
clearPersonalCache(USER)
}

beforeEach(async () => {
await rm(personalLoopatDir(USER), { recursive: true, force: true })
clearPersonalCache(USER)
})

afterAll(async () => {
if (ownsHome) await rm(HOME, { recursive: true, force: true })
})

describe("saveWorkspaceConfig provider replacement", () => {
test("replaces submitted provider map while preserving unrelated config", async () => {
await saveWorkspaceConfig({
knowledge: { git: "git@example.com:team/knowledge.git" },
serveDomain: "serve.example.com",
providers: {
keep: {
baseUrl: "https://keep.example",
apiKey: "sk-keep",
models: [{ id: "keep-model" }],
enabled: true,
},
remove: {
baseUrl: "https://remove.example",
apiKey: "sk-remove",
models: [{ id: "remove-model" }],
enabled: true,
},
},
default: "keep",
})

await saveWorkspaceConfig({
providers: {
keep: {
baseUrl: "https://keep-new.example",
models: [{ id: "keep-model" }],
enabled: false,
},
},
})

const cfg = await loadConfig()
expect(Object.keys(cfg.providers ?? {})).toEqual(["keep"])
expect(cfg.providers?.keep.apiKey).toBe("sk-keep")
expect(cfg.providers?.keep.baseUrl).toBe("https://keep-new.example")
expect(cfg.providers?.keep.enabled).toBe(false)
expect(cfg.knowledge?.git).toBe("git@example.com:team/knowledge.git")
expect(cfg.serveDomain).toBe("serve.example.com")
})
})

describe("provider response availability", () => {
test("includes unresolved personal ${VAR} providers as disabled with missing var metadata", async () => {
await saveWorkspaceConfig({
knowledge: { git: "" },
providers: {},
default: "",
})
await writePersonalProviders({
default: "ghost",
ghost: {
baseUrl: "https://ghost.example",
apiKey: "${GHOST_API_KEY}",
models: [{ id: "ghost-model" }],
},
})

const j = await getProvidersResponse(USER)

expect(j.providers.ghost).toMatchObject({
baseUrl: "https://ghost.example",
source: "personal",
enabled: false,
hasKey: false,
missingVar: "GHOST_API_KEY",
})
expect(j.providers.ghost.models).toEqual([{ id: "ghost-model" }])
})

test("defaults to keyed personal provider when personal has no explicit default", async () => {
await saveWorkspaceConfig({
knowledge: { git: "" },
providers: {
workspace: {
baseUrl: "https://workspace.example",
apiKey: "sk-workspace",
models: [{ id: "workspace-model" }],
enabled: true,
},
},
default: "workspace",
})
await writePersonalProviders({
personal: {
baseUrl: "https://personal.example",
apiKey: "${PERSONAL_API_KEY}",
models: [{ id: "personal-model" }],
},
})
await writeFile(personalVaultEnvPath(USER, "default", "PERSONAL_API_KEY"), "sk-personal\n")
clearPersonalCache(USER)

const j = await getProvidersResponse(USER)

expect(j.default).toBe("personal")
expect(j.providers.personal).toMatchObject({
source: "personal",
enabled: true,
hasKey: true,
})
})
})
2 changes: 1 addition & 1 deletion web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,7 @@ export async function listTopics(): Promise<TopicAggregate[]> {
}

export type ModelEntry = { id: string; maxContextTokens?: number }
export type ProviderInfo = { model?: string; models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean }
export type ProviderInfo = { model?: string; models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean; missingVar?: string }
export type ProvidersResponse = { providers: Record<string, ProviderInfo>; default: string }
export async function getProviders(): Promise<ProvidersResponse> {
const r = await apiFetch("/api/providers")
Expand Down
16 changes: 16 additions & 0 deletions web/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export default function ModelSelector() {
return map;
}, [flatModels]);

const missingKeyProviders = useMemo(() => {
if (!providers) return [];
return Object.entries(providers.providers).filter(([, info]) => !info.hasKey && !!info.missingVar);
}, [providers]);

const isClaudeModel = (model: string) => model.toLowerCase().startsWith("claude");
const onPick = useCallback(async (item: FlatModel) => {
setOpen(false);
Expand Down Expand Up @@ -226,6 +231,17 @@ export default function ModelSelector() {
)}
</div>

{missingKeyProviders.length > 0 && (
<div className="px-4 py-2 border-t border-amber-100 bg-amber-50/70 text-[11px] text-amber-700 shrink-0 space-y-0.5">
{missingKeyProviders.map(([name, info]) => (
<div key={name}>
Missing key: <span className="font-medium">{name}</span> needs{" "}
<code className="font-mono">{info.missingVar}</code> in Settings / AI Providers.
</div>
))}
</div>
)}

{/* Footer with shortcuts */}
<div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 shrink-0">
<span className="flex items-center gap-1">
Expand Down
6 changes: 5 additions & 1 deletion web/src/components/dialog/AdminDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ export function WorkspacePanel() {
const renameProvider = (oldName: string) => {
const newName = provRenameValue.trim()
if (!newName || newName === oldName || newName === "default") { setEditingProvName(null); return }
const warnReEnterKey = !!(draft?.providers[oldName]?.hasKey && !draft.providers[oldName].keyDirty && !draft.providers[newName])
setDraft((d) => {
if (!d || !d.providers[oldName]) return d
if (d.providers[newName]) return d
Expand All @@ -382,8 +383,11 @@ export function WorkspacePanel() {
} else if (d.default.startsWith(`${oldName}/`)) {
newDefault = newName + d.default.slice(oldName.length)
}
return { ...d, default: newDefault, providers: { ...rest, [newName]: prov } }
return { ...d, default: newDefault, providers: { ...rest, [newName]: { ...prov, hasKey: false } } }
})
if (warnReEnterKey) {
setErr(`Renamed "${oldName}" to "${newName}". Re-enter and test the workspace API key before saving; the stored key reference cannot be preserved through this rename.`)
}
setEditingProvName(null)
}

Expand Down
Loading