The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
@@ -0,0 +1,449 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { isScalar, parse, parseDocument } from "yaml"
|
||||
import { z } from "zod"
|
||||
import { paths } from "./paths.ts"
|
||||
import { asMode, Config, Connection, ConnectionsFile, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type PermissionConfig } from "./schema.ts"
|
||||
import { runKeyCmd, substituteDeep } from "./substitute.ts"
|
||||
import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts"
|
||||
import { defaultLibrary, withInstancePersonalization } from "../lembas/personal.ts"
|
||||
import { instances } from "../lembas/login.ts"
|
||||
import { findRef, modelRefs, type ModelRef } from "../provider/refs.ts"
|
||||
|
||||
export class ConfigError extends Error {}
|
||||
|
||||
export interface Loaded {
|
||||
config: Config
|
||||
/** A `type: webui` entry is here as the openai-chat connection it is spoken to as, with the
|
||||
* instance's models and a `webui` mark. */
|
||||
connections: Record<string, Connection & { webui?: WebuiMark }>
|
||||
/** Connections whose {env:}/{file:} substitution failed, with the reason. Unusable, not fatal. */
|
||||
broken: Record<string, string>
|
||||
/** Permission rulesets in precedence order (global first). */
|
||||
permissions: PermissionConfig[]
|
||||
/** Where each of `permissions` came from: a project's rules cannot loosen a global one. */
|
||||
permissionSources: ("global" | "project")[]
|
||||
/** MCP servers, substituted; where each came from. A server whose substitution failed is in
|
||||
* `broken` under `mcp:<name>`. */
|
||||
mcp: Record<string, McpServer & { source: "global" | "project" }>
|
||||
warnings: string[]
|
||||
files: string[]
|
||||
/** `instructions` from both files, kept apart: a project's may only name files inside it. */
|
||||
instructions: { path: string; global: boolean }[]
|
||||
/** Every model with its ref (an instance's by provider, `deepseek/deepseek-flash`). */
|
||||
refs: ModelRef[]
|
||||
/** The webui connection whose account's personalization is in force: /settings writes
|
||||
* personality, personality_custom and instructions back there. */
|
||||
personalFrom?: string
|
||||
}
|
||||
|
||||
function readYaml(file: string): unknown {
|
||||
try {
|
||||
return parse(readFileSync(file, "utf8")) ?? {}
|
||||
} catch (e) {
|
||||
throw new ConfigError(`${file}: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validate<S extends z.ZodType>(schema: S, value: unknown, file: string): z.infer<S> {
|
||||
const r = schema.safeParse(value)
|
||||
if (!r.success) {
|
||||
const lines = r.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
|
||||
throw new ConfigError(`${file} is invalid:\n${lines.join("\n")}`)
|
||||
}
|
||||
return r.data
|
||||
}
|
||||
|
||||
/** config.yaml: everything substituted except `mcp` and `voice`, whose parts are substituted one
|
||||
* by one later (a missing variable for one server or endpoint must not break the whole file). */
|
||||
const MODE_RANK = { auto: 0, edit: 1, manual: 2, plan: 3 } as const
|
||||
|
||||
/** A project's change to a global server, kept only where it narrows: off, fewer tools, less in
|
||||
* the prompt, shorter waits. It never turns on what the user turned off. */
|
||||
function narrowed(base: McpServer, o: Record<string, unknown>): McpServer {
|
||||
const out: Record<string, unknown> = { ...base }
|
||||
for (const k of ["enabled", "instructions", "prompts", "resources"] as const) if (o[k] === false) out[k] = false
|
||||
for (const k of ["timeout", "connect_timeout"] as const) {
|
||||
const v = o[k]
|
||||
if (typeof v === "number" && (base[k] === undefined || v < base[k]!)) out[k] = v
|
||||
}
|
||||
const t = (o.tools ?? {}) as { include?: string[]; exclude?: string[] }
|
||||
if (t.include || t.exclude) {
|
||||
const include = base.tools?.include ? (t.include ? base.tools.include.filter((x) => t.include!.includes(x)) : base.tools.include) : t.include
|
||||
const exclude = [...new Set([...(base.tools?.exclude ?? []), ...(t.exclude ?? [])])]
|
||||
out.tools = { ...(include ? { include } : {}), ...(exclude.length ? { exclude } : {}) }
|
||||
}
|
||||
return out as McpServer
|
||||
}
|
||||
|
||||
// What a project may change about a server the global config defines, without redefining it.
|
||||
const MCP_OVERRIDE = new Set(["enabled", "tools", "timeout", "connect_timeout", "prompts", "resources", "instructions"])
|
||||
|
||||
/** A project's config with every {env:}/{file:} value taken out (`removed` names them): only the
|
||||
* user's own files may put a secret from the environment or a file into a URL or a header. */
|
||||
function withoutPlaceholders(v: unknown, path: string, removed: string[]): unknown {
|
||||
if (typeof v === "string") return /\{(env|file):[^}]+\}/.test(v) ? (removed.push(path), undefined) : v
|
||||
if (Array.isArray(v)) return v.map((x, i) => withoutPlaceholders(x, `${path}[${i}]`, removed)).filter((x) => x !== undefined)
|
||||
if (v && typeof v === "object") {
|
||||
// A placeholder in one of its own values takes out the whole entry (a search service, an MCP
|
||||
// server): what is left of it would be broken, or would go where it was not meant to.
|
||||
const own = Object.entries(v).filter(([, x]) => typeof x === "string" && /\{(env|file):[^}]+\}/.test(x))
|
||||
if (own.length) {
|
||||
for (const [k] of own) removed.push(path ? `${path}.${k}` : k)
|
||||
return undefined
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, x] of Object.entries(v)) {
|
||||
const y = withoutPlaceholders(x, path ? `${path}.${k}` : k, removed)
|
||||
if (y !== undefined) out[k] = y
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/** What was taken out or reshaped, read from an older config, so an old file still
|
||||
* starts: crowd chats and named personalities are gone (dropped, said once), a personality that
|
||||
* is not a preset any more becomes none, and `instructions` as a list of files is now
|
||||
* `instruction_files` (`instructions` became the custom-instructions text). */
|
||||
function legacyKeys(all: Record<string, unknown>, file: string, notes: string[]) {
|
||||
if ("crowd" in all) {
|
||||
delete all.crowd
|
||||
notes.push(`${file}: crowd chats were removed; \`crowd\` is ignored — delete it`)
|
||||
}
|
||||
if ("personalities" in all) {
|
||||
delete all.personalities
|
||||
notes.push(`${file}: named personalities were removed; \`personalities\` is ignored — personality is one of ${PERSONALITIES.join(", ")} now (custom takes personality_custom)`)
|
||||
}
|
||||
const p = all.personality
|
||||
if (p !== undefined && !(typeof p === "string" && (p === "" || (PERSONALITIES as readonly string[]).includes(p)))) {
|
||||
delete all.personality
|
||||
if (!(typeof p === "string" && ["none", "default", "neutral"].includes(p.toLowerCase())))
|
||||
notes.push(`${file}: personality "${String(p)}" is not a preset any more (${PERSONALITIES.join(", ")}); none is used`)
|
||||
}
|
||||
// One file written as a plain string, not a list: read as the list of that one, not dropped
|
||||
// without a word by the schema.
|
||||
if (typeof all.instruction_files === "string") {
|
||||
all.instruction_files = all.instruction_files.trim() ? [all.instruction_files] : []
|
||||
notes.push(`${file}: instruction_files is a list; the one file given is read as a list of one — write it as [${String((all.instruction_files as string[])[0] ?? "")}]`)
|
||||
}
|
||||
if (Array.isArray(all.instructions)) {
|
||||
all.instruction_files = [...(Array.isArray(all.instruction_files) ? all.instruction_files : []), ...all.instructions]
|
||||
delete all.instructions
|
||||
notes.push(`${file}: \`instructions\` is the custom-instructions text now; the list of files is read as instruction_files — rename it`)
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig(file: string, label: string, notes: string[], dropped?: string[], mcpOverrides?: Record<string, Record<string, unknown>>, placeholders?: string[]): Config {
|
||||
const raw = readYaml(file)
|
||||
let all = raw && typeof raw === "object" ? { ...(raw as Record<string, unknown>) } : {}
|
||||
if (placeholders) all = withoutPlaceholders(all, "", placeholders) as Record<string, unknown>
|
||||
legacyKeys(all, file, notes)
|
||||
// A project's global-only keys go before validation: ignored, so their shape cannot break the file.
|
||||
if (dropped)
|
||||
for (const k of GLOBAL_ONLY)
|
||||
if (k in all) {
|
||||
delete all[k]
|
||||
dropped.push(k)
|
||||
}
|
||||
// mcp, voice and search are filled in later, one server / half / service at a time: a key that
|
||||
// cannot be read turns off that one thing instead of the whole file.
|
||||
// update: filled in when an update is looked for — a token file missing must not stop a start.
|
||||
let { mcp, voice, search, update, ...rest } = all
|
||||
// Names the harness spec renamed (v1): an old mode or permission key is read as the new one.
|
||||
if (typeof rest.mode === "string") rest.mode = asMode(rest.mode) ?? rest.mode
|
||||
if (rest.permission && typeof rest.permission === "object" && !Array.isArray(rest.permission)) rest.permission = renamePermissionKeys(rest.permission as Record<string, unknown>)
|
||||
// A project's entry that only switches a global server off or narrows it (no command, no url)
|
||||
// is kept apart: on its own it is not a valid server.
|
||||
if (mcpOverrides && mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
|
||||
const full: Record<string, unknown> = {}
|
||||
for (const [name, v] of Object.entries(mcp as Record<string, unknown>)) {
|
||||
const keys = v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v) : []
|
||||
if (keys.length && keys.every((k) => MCP_OVERRIDE.has(k))) mcpOverrides[name] = v as Record<string, unknown>
|
||||
else full[name] = v
|
||||
}
|
||||
mcp = full
|
||||
}
|
||||
return validate(Config, { ...substituteDeep(rest, label), ...(mcp === undefined ? {} : { mcp }), ...(voice === undefined ? {} : { voice }), ...(search === undefined ? {} : { search }), ...(update === undefined ? {} : { update }) }, file)
|
||||
}
|
||||
|
||||
/** Objects merge key by key; anything else is replaced. Permission is handled separately (it stacks). */
|
||||
function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
|
||||
const out: Record<string, unknown> = { ...a }
|
||||
for (const [k, v] of Object.entries(b)) {
|
||||
const prev = out[k]
|
||||
out[k] =
|
||||
v && typeof v === "object" && !Array.isArray(v) && prev && typeof prev === "object" && !Array.isArray(prev)
|
||||
? merge(prev as Record<string, unknown>, v as Record<string, unknown>)
|
||||
: v
|
||||
}
|
||||
return out as T
|
||||
}
|
||||
|
||||
// Keys a project may never set: they would let a cloned repository weaken the floor. The custom
|
||||
// instructions and a custom personality's text are the person's own words, put last in the system
|
||||
// prompt as theirs: a repository writing them would speak in the user's voice.
|
||||
// A personality preset's name is only a choice among the shipped texts, and stays a project's to make.
|
||||
const GLOBAL_ONLY = ["hardline_extra", "hardline_disable", "voice", "update", "settings_tool", "embedding", "remote", "library", "instructions", "personality_custom"] as const
|
||||
|
||||
export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean } = {}): Loaded {
|
||||
const warnings: string[] = []
|
||||
const files: string[] = []
|
||||
|
||||
const globalFile = join(paths.config, "config.yaml")
|
||||
let config: Config = {}
|
||||
const permissions: PermissionConfig[] = []
|
||||
const permissionSources: ("global" | "project")[] = []
|
||||
if (existsSync(globalFile)) {
|
||||
config = readConfig(globalFile, "config.yaml", warnings)
|
||||
files.push(globalFile)
|
||||
if (config.permission) permissions.push(config.permission), permissionSources.push("global")
|
||||
}
|
||||
const instructions: { path: string; global: boolean }[] = (config.instruction_files ?? []).map((path) => ({ path, global: true }))
|
||||
|
||||
const mcpSources: Record<string, "global" | "project"> = {}
|
||||
for (const name of Object.keys(config.mcp ?? {})) mcpSources[name] = "global"
|
||||
|
||||
if (opts.projectConfigDir && opts.trusted) {
|
||||
const projectFile = join(opts.projectConfigDir, "config.yaml")
|
||||
if (existsSync(projectFile)) {
|
||||
const dropped: string[] = []
|
||||
const placeholders: string[] = []
|
||||
const overrides: Record<string, Record<string, unknown>> = {}
|
||||
const project = readConfig(projectFile, "project config.yaml", warnings, dropped, overrides, placeholders)
|
||||
for (const key of dropped) warnings.push(`${projectFile}: \`${key}\` is honoured only in the global config; ignored`)
|
||||
for (const at of placeholders) warnings.push(`${projectFile}: ${at} uses {env:} or {file:}, which only the global config may; ignored`)
|
||||
files.push(projectFile)
|
||||
// The settings tool's gate is global-only (settings_tool); a project rule for it would open it.
|
||||
if (project.permission && "settings" in project.permission) {
|
||||
delete project.permission.settings
|
||||
warnings.push(`${projectFile}: permission.settings is honoured only in the global config (settings_tool); ignored`)
|
||||
}
|
||||
if (project.permission) permissions.push(project.permission), permissionSources.push("project")
|
||||
// A project may make the starting mode stricter than the user's, never looser.
|
||||
const own = config.mode ?? "manual"
|
||||
if (project.mode && MODE_RANK[project.mode] < MODE_RANK[own]) {
|
||||
warnings.push(`${projectFile}: mode ${project.mode} is looser than your own (${own}); a project can only make it stricter — ${own} it is`)
|
||||
delete project.mode
|
||||
}
|
||||
for (const name of Object.keys(project.mcp ?? {})) mcpSources[`project:${name}`] = "project"
|
||||
const { permission: _, mcp: projectMcp, instruction_files: projectInstructions, ...rest } = project
|
||||
// A search service the project names replaces yours whole: merged field by field, your api_key
|
||||
// would go to the project's base_url (the same reason as for MCP servers below).
|
||||
const ownSearch = { ...config.search }
|
||||
config = merge(config, rest)
|
||||
for (const svc of ["searxng", "firecrawl"] as const)
|
||||
if (rest.search?.[svc]) config = { ...config, search: { ...ownSearch, ...config.search, [svc]: rest.search[svc] } }
|
||||
for (const path of projectInstructions ?? []) instructions.push({ path, global: false })
|
||||
// A server the project defines replaces the global one whole: merged field by field, the
|
||||
// global one's headers (a token) would go to the project's url.
|
||||
const servers: Record<string, McpServer> = { ...config.mcp, ...projectMcp }
|
||||
for (const [name, o] of Object.entries(overrides)) {
|
||||
const base = config.mcp?.[name]
|
||||
if (!base) {
|
||||
warnings.push(`${projectFile}: mcp.${name} changes a server the global config does not define; ignored`)
|
||||
continue
|
||||
}
|
||||
const r = McpServer.safeParse(narrowed(base, o))
|
||||
if (r.success) {
|
||||
servers[name] = r.data
|
||||
mcpSources[`project:${name}`] = "global"
|
||||
} else warnings.push(`${projectFile}: mcp.${name}: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}; ignored`)
|
||||
}
|
||||
if (Object.keys(servers).length) config = { ...config, mcp: servers }
|
||||
}
|
||||
}
|
||||
|
||||
// `skin` was renamed `theme`; an old config keeps its look.
|
||||
if (config.skin !== undefined) {
|
||||
if (config.theme === undefined) config = { ...config, theme: config.skin }
|
||||
warnings.push("config: `skin` is now called `theme`; rename it (the value is used meanwhile)")
|
||||
}
|
||||
|
||||
const connFile = join(paths.config, "connections.yaml")
|
||||
const connections: Loaded["connections"] = {}
|
||||
const broken: Record<string, string> = {}
|
||||
if (existsSync(connFile)) {
|
||||
files.push(connFile)
|
||||
const mode = statSync(connFile).mode & 0o777
|
||||
if (mode & 0o077) warnings.push(`${connFile} is mode ${mode.toString(8)}; chmod 600 it — it references credentials`)
|
||||
// Validate the raw file, then substitute each connection on its own: a missing
|
||||
// variable for one connection must not stop the others from working.
|
||||
const raw = validate(ConnectionsFile, readYaml(connFile), connFile).connections
|
||||
for (const [name, c] of Object.entries(raw)) {
|
||||
try {
|
||||
if ((c as { type?: string }).type === "webui") {
|
||||
const w = WebuiConnection.safeParse(substituteDeep(c, `connections.${name}`))
|
||||
if (w.success) {
|
||||
const c = (connections[name] = expandWebui(name, w.data))
|
||||
if (refreshFailures[name])
|
||||
warnings.push(
|
||||
`${name}: the instance could not be asked for its models (${refreshFailures[name]}); ${c.webui.fetched_at ? `using what it said at ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "none known yet"}`,
|
||||
)
|
||||
}
|
||||
else broken[name] = `once {env:}/{file:} are filled in: ${w.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
||||
continue
|
||||
}
|
||||
// Again once filled in: "{env:LLM_URL}" passed as a placeholder; what it became must be a URL.
|
||||
const r = Connection.safeParse(substituteDeep(c, `connections.${name}`))
|
||||
if (r.success) connections[name] = r.data
|
||||
else broken[name] = `once {env:}/{file:} are filled in: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
||||
} catch (e) {
|
||||
broken[name] = (e as Error).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The webui connection a voice half or the search means: the one named, or the only one. A
|
||||
* string says why there is none. */
|
||||
const webuiFor = (named?: string): { name: string; c: Loaded["connections"][string] } | string => {
|
||||
if (named) return connections[named]?.webui ? { name: named, c: connections[named]! } : broken[named] ? `${named} is unusable: ${broken[named]}` : `there is no webui connection named ${named}`
|
||||
const all = Object.entries(connections).filter(([, c]) => c.webui)
|
||||
if (all.length === 1) return { name: all[0]![0], c: all[0]![1] }
|
||||
return all.length ? `there are several webui connections (${all.map(([n]) => n).join(", ")}); name one with connection:` : "there is no webui connection (lembas login)"
|
||||
}
|
||||
|
||||
const mcp: Loaded["mcp"] = {}
|
||||
for (const [name, server] of Object.entries(config.mcp ?? {})) {
|
||||
try {
|
||||
mcp[name] = { ...substituteDeep(server, `mcp.${name}`), source: mcpSources[`project:${name}`] ?? mcpSources[name] ?? "global" }
|
||||
} catch (e) {
|
||||
broken[`mcp:${name}`] = (e as Error).message
|
||||
warnings.push(`MCP server ${name} is off: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Voice: each half on its own; a half whose substitution fails is off, with a warning.
|
||||
if (config.voice) {
|
||||
const v = { ...config.voice }
|
||||
for (const half of ["stt", "tts"] as const) {
|
||||
if (!v[half]) continue
|
||||
try {
|
||||
v[half] = substituteDeep(v[half], `voice.${half}`) as never
|
||||
} catch (e) {
|
||||
delete v[half]
|
||||
broken[`voice:${half}`] = (e as Error).message
|
||||
warnings.push(`voice ${half === "stt" ? "input" : "output"} is off: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
// provider: webui — the instance's speech servers, reached at <url>/v1 with this machine's key.
|
||||
// An instance that is not configured here falls back (or the half is off), with a warning.
|
||||
for (const half of ["stt", "tts"] as const) {
|
||||
const h = v[half]
|
||||
if (h?.provider !== "webui") continue
|
||||
const found = webuiFor(h.connection)
|
||||
if (typeof found !== "string") {
|
||||
try {
|
||||
v[half] = { ...h, base_url: `${found.c.webui!.url}/v1`, api_key: resolveKey(found.name, found.c), tls: found.c.tls, connection: found.name } as never
|
||||
continue
|
||||
} catch (e) {
|
||||
broken[`voice:${half}`] = (e as Error).message
|
||||
}
|
||||
}
|
||||
const why = typeof found === "string" ? found : broken[`voice:${half}`]!
|
||||
if (h.fallback) {
|
||||
v[half] = h.fallback as never
|
||||
warnings.push(`voice ${half === "stt" ? "input" : "output"}: webui — ${why}; using its fallback`)
|
||||
} else {
|
||||
delete v[half]
|
||||
warnings.push(`voice ${half === "stt" ? "input" : "output"} is off: webui — ${why}`)
|
||||
}
|
||||
}
|
||||
config = { ...config, voice: v }
|
||||
}
|
||||
|
||||
// Search: each service on its own, like voice — a key that cannot be read turns off that
|
||||
// service (the next in search.order is used), with a warning, and LLeMbas CLI still starts.
|
||||
if (config.search) {
|
||||
const sr = { ...config.search }
|
||||
for (const svc of ["searxng", "firecrawl"] as const) {
|
||||
if (!sr[svc]) continue
|
||||
try {
|
||||
const filled = substituteDeep(sr[svc], `search.${svc}`) as { base_url?: string }
|
||||
if (filled.base_url !== undefined && !URL.canParse(filled.base_url)) throw new Error(`base_url is not a URL once {env:}/{file:} are filled in`)
|
||||
sr[svc] = filled as never
|
||||
} catch (e) {
|
||||
delete sr[svc]
|
||||
broken[`search:${svc}`] = (e as Error).message
|
||||
warnings.push(`search: ${svc} is off: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
// webui: the instance's own search (and fetch), with this machine's key.
|
||||
if (sr.order?.includes("webui") || sr.webui || sr.fetch === "webui") {
|
||||
const found = webuiFor(sr.webui?.connection)
|
||||
let why = typeof found === "string" ? found : ""
|
||||
if (typeof found !== "string") {
|
||||
try {
|
||||
sr.webui = { connection: found.name, base_url: found.c.webui!.url, api_key: resolveKey(found.name, found.c), tls: found.c.tls } as never
|
||||
} catch (e) {
|
||||
why = (e as Error).message
|
||||
}
|
||||
}
|
||||
if (why) {
|
||||
delete sr.webui
|
||||
if (sr.fetch === "webui") sr.fetch = "local"
|
||||
warnings.push(`search: webui is off — ${why}`)
|
||||
}
|
||||
}
|
||||
config = { ...config, search: sr }
|
||||
}
|
||||
|
||||
// Logged in to one instance: the account's personalization in place of the local keys,
|
||||
// where the instance offers it; and its library when nobody chose one.
|
||||
const personal = withInstancePersonalization(config)
|
||||
config = personal.config
|
||||
if (config.library === undefined && defaultLibrary()) config = { ...config, library: "lembas" }
|
||||
|
||||
// An instance that speaks protocol 2 serves `<provider>/<model>` ids.
|
||||
const logins = instances()
|
||||
for (const [name, c] of Object.entries(connections)) if (c.webui && logins[name]?.protocols?.includes(2)) c.webui.v2 = true
|
||||
const refs = modelRefs(connections, Object.keys(logins))
|
||||
// A config.yaml naming the model the way it was named before providers (`example/deepseek-flash`)
|
||||
// is rewritten to the new name, once (migrateRefs). Only that form: a pinned `<login>/<provider>/
|
||||
// <model>` means what it says, and `embedding` stays as written — its name keys the stored vectors.
|
||||
for (const key of ["model", "small_model"] as const) {
|
||||
const v = config[key]
|
||||
if (typeof v !== "string") continue
|
||||
const r = findRef(connections, refs, v)
|
||||
if (!r?.old) continue
|
||||
config = { ...config, [key]: r.ref }
|
||||
migrateRef(globalFile, key, v, r.ref)
|
||||
}
|
||||
|
||||
return { config, connections, broken, permissions, permissionSources, warnings, files, mcp, instructions, refs, ...(personal.connection ? { personalFrom: personal.connection } : {}) }
|
||||
}
|
||||
|
||||
/** `key: old` in the global config.yaml becomes `key: now` — once per key and old value, recorded
|
||||
* in the state directory; the scalar edited in place (its anchor and comment kept), re-read just
|
||||
* before writing and written atomically, so a login writing the same file meanwhile is not lost. */
|
||||
function migrateRef(file: string, key: string, old: string, now: string) {
|
||||
const record = join(paths.state, "migrations.json")
|
||||
let done: Record<string, string> = {}
|
||||
try {
|
||||
done = JSON.parse(readFileSync(record, "utf8")) as Record<string, string>
|
||||
} catch {}
|
||||
const id = `provider-refs:${key}:${old}`
|
||||
if (done[id]) return
|
||||
try {
|
||||
if (existsSync(file)) {
|
||||
const doc = parseDocument(readFileSync(file, "utf8"))
|
||||
const node = doc.get(key, true)
|
||||
if (isScalar(node) && node.value === old) {
|
||||
node.value = now
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
writeFileSync(tmp, doc.toString())
|
||||
renameSync(tmp, file)
|
||||
}
|
||||
}
|
||||
done[id] = now
|
||||
mkdirSync(paths.state, { recursive: true })
|
||||
writeFileSync(record, JSON.stringify(done, null, 2) + "\n")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** The API key for a connection: `api_key` (already substituted) or the output of `key_cmd`. */
|
||||
export function resolveKey(name: string, c: Connection): string | undefined {
|
||||
if (c.api_key) return c.api_key
|
||||
if (c.key_cmd) return runKeyCmd(c.key_cmd, `connections.${name}.key_cmd`)
|
||||
return undefined
|
||||
}
|
||||
Reference in new issue
Block a user