From a6fd25936c8f14d16af7f0933828565540f83f5b Mon Sep 17 00:00:00 2001 From: MicroGrey Date: Thu, 4 Jun 2026 21:25:37 +0800 Subject: [PATCH] fix(offload): resolve local-llm apiKey from auth-profile store as fallback When offload.model references a provider whose apiKey is managed via OpenClaw's auth-profiles mechanism (the recommended default, e.g. set up with 'openclaw auth'), the local-llm initializer previously only read models.providers[provider].apiKey in the config tree. If the key was absent there, LocalLlmClient construction was skipped and L1/L1.5/L2 were disabled with a misleading "missing apiKey" error -- even though the key existed and the main agent model worked fine. Fix: add src/offload/auth-profile-key.ts, a synchronous helper that loads the auth-profile store via openclaw/plugin-sdk/provider-auth and returns the first api_key-type credential for the provider. The helper is guarded with try/catch so older OpenClaw versions that do not expose this SDK subpath degrade silently to the previous behavior. In index.ts, the apiKey lookup becomes: providerCfg?.apiKey ?? resolveApiKeyFromAuthProfile(api, providerKey, logger) registerOffload() keeps its synchronous contract; no race is introduced around backendClient assignment. Closes #90 Signed-off-by: MicroGrey --- src/offload/auth-profile-key.ts | 119 ++++++++++++++++++++++++++++++++ src/offload/index.ts | 8 ++- 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 src/offload/auth-profile-key.ts diff --git a/src/offload/auth-profile-key.ts b/src/offload/auth-profile-key.ts new file mode 100644 index 0000000..4829c33 --- /dev/null +++ b/src/offload/auth-profile-key.ts @@ -0,0 +1,119 @@ +/** + * Auth-profile API key resolver for offload local mode. + * + * OpenClaw stores model credentials in two places: + * 1. `models.providers[provider].apiKey` — plaintext in openclaw.json + * 2. auth-profiles store — the credential "vault" populated by `openclaw auth` + * + * The offload local-llm path historically only read location (1). When users + * manage their keys via auth-profiles (location 2) — the OpenClaw-recommended + * default — the lookup misses and L1/L1.5/L2 get disabled (see issue #90). + * + * This module provides a SYNCHRONOUS fallback that reads the key from the + * auth-profile store, so `registerOffload` keeps its synchronous contract and + * no race is introduced around `backendClient`. + * + * Compatibility: the `openclaw/plugin-sdk/provider-auth` subpath only exists on + * newer OpenClaw versions. All host calls are guarded so that on older hosts + * (or any unexpected failure) we silently fall back to the previous behavior + * of "config-tree only". + */ +import { createRequire } from "node:module"; + +import type { PluginLogger } from "./types.js"; + +const TAG = "[context-offload] [auth-profile]"; + +// The plugin is ESM ("type": "module"), so `require` is not a global. Create a +// CJS require bound to this module's URL — matches the pattern in the plugin +// entry (index.ts) and lets us tolerate a missing SDK subpath on older hosts. +const _require = createRequire(import.meta.url); + +/** + * Resolve an API key for `providerKey` from OpenClaw's auth-profile store. + * + * Returns the plaintext key for the first `api_key`-type profile bound to the + * provider, or `undefined` when nothing usable is found (no profile, only + * oauth/token credentials, indirect keyRef-only storage, or an older host that + * does not expose the auth-profile SDK). + * + * This is intentionally synchronous: the underlying store loaders + * (`ensureAuthProfileStore`, `listProfilesForProvider`) are synchronous, which + * lets the caller resolve the key inline without awaiting. + * + * @param api - OpenClaw plugin api (its `config` is forwarded to the resolver). + * @param providerKey - Provider name parsed from the model ref (e.g. "xiaomi"). + * @param logger - Optional logger; failures are reported at debug level. + */ +export function resolveApiKeyFromAuthProfile( + api: { config?: unknown }, + providerKey: string, + logger?: PluginLogger, +): string | undefined { + try { + // Lazily load the SDK subpath so a missing export on older OpenClaw + // versions degrades gracefully instead of crashing module load. + const sdk = loadProviderAuthSdk(); + if (!sdk) return undefined; + + const { ensureAuthProfileStore, listProfilesForProvider, resolveOpenClawAgentDir } = sdk; + if ( + typeof ensureAuthProfileStore !== "function" || + typeof listProfilesForProvider !== "function" + ) { + return undefined; + } + + const agentDir = + typeof resolveOpenClawAgentDir === "function" ? resolveOpenClawAgentDir() : undefined; + + const store = ensureAuthProfileStore(agentDir, { config: api.config }); + if (!store || typeof store !== "object") return undefined; + + const profileIds = listProfilesForProvider(store, providerKey); + if (!Array.isArray(profileIds) || profileIds.length === 0) return undefined; + + const profiles = (store as { profiles?: Record }).profiles ?? {}; + for (const id of profileIds) { + const cred = profiles[id] as { type?: string; key?: string } | undefined; + // Only api_key credentials carry a directly-usable plaintext key. + // oauth/token profiles, or api_key profiles that store only a keyRef + // (indirect/keychain), cannot be consumed by the OpenAI-compatible + // local-llm caller, so we skip them. + if (cred?.type === "api_key" && typeof cred.key === "string" && cred.key.length > 0) { + logger?.debug?.(`${TAG} Resolved api key for provider "${providerKey}" from profile "${id}"`); + return cred.key; + } + } + return undefined; + } catch (err) { + logger?.debug?.( + `${TAG} Auth-profile lookup unavailable for provider "${providerKey}": ${err instanceof Error ? err.message : String(err)}`, + ); + return undefined; + } +} + +interface ProviderAuthSdk { + ensureAuthProfileStore?: ( + agentDir?: string, + options?: { config?: unknown }, + ) => { profiles?: Record } | null | undefined; + listProfilesForProvider?: (store: unknown, provider: string) => string[]; + resolveOpenClawAgentDir?: () => string; +} + +/** + * Load the `openclaw/plugin-sdk/provider-auth` subpath. + * + * Uses the module-scoped CJS require so a missing subpath (older hosts) + * surfaces as a caught error rather than an unhandled module-resolution + * failure. + */ +function loadProviderAuthSdk(): ProviderAuthSdk | undefined { + try { + return _require("openclaw/plugin-sdk/provider-auth") as ProviderAuthSdk; + } catch { + return undefined; + } +} diff --git a/src/offload/index.ts b/src/offload/index.ts index c6e7c5d..7d0d724 100644 --- a/src/offload/index.ts +++ b/src/offload/index.ts @@ -52,6 +52,7 @@ import type { OffloadConfig } from "../config.js"; import type { PluginConfig, PluginLogger } from "./types.js"; import { BackendClient } from "./backend-client.js"; import { LocalLlmClient } from "./local-llm/index.js"; +import { resolveApiKeyFromAuthProfile } from "./auth-profile-key.js"; import type { L1Request, L15Request, L2Request } from "./backend-client.js"; import { parseMmdMeta } from "./mmd-meta.js"; import { sanitizeText, writeRefMd } from "./storage.js"; @@ -368,7 +369,10 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { const models = (api.config as any)?.models; const providerCfg = models?.providers?.[providerKey]; const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL; - const apiKey = providerCfg?.apiKey; + // Key resolution: prefer the plaintext key in models.providers, then fall + // back to OpenClaw's auth-profile store (issue #90). The fallback is a + // synchronous no-op on hosts that don't expose the auth-profile SDK. + const apiKey = providerCfg?.apiKey ?? resolveApiKeyFromAuthProfile(api, providerKey, logger); if (baseUrl && apiKey) { backendClient = new LocalLlmClient( @@ -377,7 +381,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { ); } else { logger.error( - `[context-offload] Local LLM mode failed: provider "${providerKey}" not found or missing baseUrl/apiKey in models.providers. ` + + `[context-offload] Local LLM mode failed: provider "${providerKey}" not found or missing baseUrl/apiKey in models.providers (or auth profiles). ` + `L1/L1.5/L2 disabled.`, ); }