UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

193 lines (192 loc) • 8.51 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { isRecord } from "./common.js"; export const EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS = [ "model", "messages", "temperature", "maxtokens", "responseformat", "stream", "streamoptions", "enablethinking", "reasoningeffort", "chattemplatekwargs", ]; export const EXTRA_PARAMS_CREDENTIAL_KEYS = [ "authorization", "headers", "apikey", "token", "password", "secret", "cookie", "setcookie", ]; const PROTECTED_TOP_LEVEL_KEYS = new Set(EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS); const CREDENTIAL_KEYS = new Set(EXTRA_PARAMS_CREDENTIAL_KEYS); // ── Protected-key remedies (#852) ─────────────────────────────────────────── // // `EXTRA_PARAMS_PROTECTED_TOP_LEVEL_KEYS` rightly stops a provider extra from // shadowing an AKM-managed field, but naming the rule without naming the fix // leaves the reader stuck (#852). Every protected key gets a one-line remedy // baked into its issue message; keys with a genuine scalar first-class field // are also eligible for the automatic config-load lift below. /** normalizeExtraParamKey(key) -> the first-class engine field it shadows. */ const LEGACY_EXTRA_PARAMS_FIELD = { model: "model", temperature: "temperature", maxtokens: "maxTokens", enablethinking: "enableThinking", reasoningeffort: "reasoningEffort", }; /** * Subset of {@link LEGACY_EXTRA_PARAMS_FIELD} that {@link liftLegacyEngineExtraParams} * will move onto the first-class field automatically. `model` is deliberately * excluded: unlike the others it was never a "no first-class field yet" * workaround (`model` has always been required on an LLM engine), so a * mismatch is far more likely a genuine mistake than a stale 0.9.1 config — * it stays a hard rejection rather than being silently reinterpreted. */ const LIFTABLE_EXTRA_PARAMS_KEYS = new Set(["temperature", "maxtokens", "enablethinking", "reasoningeffort"]); /** Remedies for protected keys with no scalar first-class field to lift onto. */ const EXTRA_PARAMS_NO_FIELD_REMEDY = { messages: "AKM builds the request messages internally — remove it from extraParams", responseformat: "AKM controls the response format internally — remove it from extraParams", stream: "AKM controls streaming internally — remove it from extraParams", streamoptions: "AKM controls streaming internally — remove it from extraParams", chattemplatekwargs: "set engines.<name>.enableThinking instead of chat_template_kwargs.enable_thinking", }; function protectedKeyRemedy(normalized) { const field = LEGACY_EXTRA_PARAMS_FIELD[normalized]; if (field) { const note = normalized === "reasoningeffort" ? " (moved to a first-class field in 0.9.2)" : ""; return `set engines.<name>.${field} instead${note}`; } return EXTRA_PARAMS_NO_FIELD_REMEDY[normalized]; } export function normalizeExtraParamKey(key) { return key.toLowerCase().replace(/[^a-z0-9]/g, ""); } /** Validate provider extras without allowing them to override AKM fields or carry credentials. */ export function validateExtraParams(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return [{ path: [], message: "must be an object" }]; } const issues = []; // A self-referential YAML anchor (`extraParams: &a { nested: *a }`) resolves // to a genuinely cyclic object — the yaml package's alias-count guard does not // catch cycles — so an unguarded walk overflowed the stack with a RangeError // that escaped task parsing. Track visited containers and stop at a revisit. const seen = new WeakSet(); const visit = (entry, path) => { if (Array.isArray(entry)) { if (seen.has(entry)) return; seen.add(entry); entry.forEach((child, index) => { visit(child, [...path, index]); }); return; } if (!entry || typeof entry !== "object") return; if (seen.has(entry)) return; seen.add(entry); for (const [key, child] of Object.entries(entry)) { const normalized = normalizeExtraParamKey(key); if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) { const remedy = protectedKeyRemedy(normalized); issues.push({ path: [key], message: remedy ? `${key} is protected by AKM — ${remedy}.` : `${key} is protected by AKM`, }); } if (CREDENTIAL_KEYS.has(normalized)) { issues.push({ path: [...path, key], message: `${key} cannot carry credentials` }); } visit(child, [...path, key]); } }; visit(value, []); return issues; } export function formatExtraParamsIssue(label, issue) { const suffix = issue.path.map((part) => (typeof part === "number" ? `[${part}]` : `.${part}`)).join(""); return `${label}${suffix} ${issue.message}`; } /** * Compute the legacy `extraParams` -> first-class-field lift for a raw * parsed config object: a 0.9.1-shaped config using (e.g.) * `extraParams.reasoning_effort` now needs `reasoningEffort` set instead, * since that field became first-class — and therefore protected — in 0.9.2 * (#852, following #815). * * Pure: never touches the filesystem. Two callers use this differently: * `akm migrate apply` (scripts/akm-migrate/migrate/config-extra-params.ts) uses the * returned `config` to persist the rewrite to disk, once; `parseAndValidateConfigText` * (src/core/config/config.ts) calls this only to detect whether a lift is * needed and discards `config` — an unmigrated config fails closed there * with a pointer to `akm migrate apply` rather than silently drifting from * what's on disk. `conflicts` (an extraParams key and its first-class field * set to different values) is a genuine authoring error in both callers and * is never auto-resolved. */ export function liftLegacyEngineExtraParams(raw) { const lifted = []; const conflicts = []; const rawEngines = raw.engines; if (!isRecord(rawEngines)) { return { config: raw, lifted, conflicts }; } const engines = {}; let anyEngineChanged = false; for (const [name, engineValue] of Object.entries(rawEngines)) { if (!isRecord(engineValue) || !isRecord(engineValue.extraParams)) { engines[name] = engineValue; continue; } const engine = { ...engineValue }; const extraParams = { ...engineValue.extraParams }; let engineChanged = false; for (const [rawKey, value] of Object.entries(engineValue.extraParams)) { const normalized = normalizeExtraParamKey(rawKey); if (!LIFTABLE_EXTRA_PARAMS_KEYS.has(normalized)) continue; const field = LEGACY_EXTRA_PARAMS_FIELD[normalized]; if (!field) continue; const existing = engine[field]; if (existing !== undefined && existing !== value) { conflicts.push({ engine: name, key: rawKey, field, extraParamsValue: value, fieldValue: existing }); continue; } delete extraParams[rawKey]; engineChanged = true; if (existing === value) { lifted.push(`engines.${name}.extraParams.${rawKey} is redundant — engines.${name}.${field} is already set to the same value; dropped the extraParams entry`); continue; } engine[field] = value; lifted.push(`engines.${name}.extraParams.${rawKey} -> engines.${name}.${field}`); } if (!engineChanged) { engines[name] = engineValue; continue; } anyEngineChanged = true; if (Object.keys(extraParams).length > 0) { engine.extraParams = extraParams; } else { delete engine.extraParams; } engines[name] = engine; } if (!anyEngineChanged) { return { config: raw, lifted, conflicts }; } return { config: { ...raw, engines }, lifted, conflicts }; }