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 <changyuhang@outlook.com>
This commit is contained in:
MicroGrey
2026-06-04 21:25:37 +08:00
parent f92b10259b
commit a6fd25936c
2 changed files with 125 additions and 2 deletions
+119
View File
@@ -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<string, unknown> }).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<string, unknown> } | 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;
}
}
+6 -2
View File
@@ -52,6 +52,7 @@ import type { OffloadConfig } from "../config.js";
import type { PluginConfig, PluginLogger } from "./types.js"; import type { PluginConfig, PluginLogger } from "./types.js";
import { BackendClient } from "./backend-client.js"; import { BackendClient } from "./backend-client.js";
import { LocalLlmClient } from "./local-llm/index.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 type { L1Request, L15Request, L2Request } from "./backend-client.js";
import { parseMmdMeta } from "./mmd-meta.js"; import { parseMmdMeta } from "./mmd-meta.js";
import { sanitizeText, writeRefMd } from "./storage.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 models = (api.config as any)?.models;
const providerCfg = models?.providers?.[providerKey]; const providerCfg = models?.providers?.[providerKey];
const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL; 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) { if (baseUrl && apiKey) {
backendClient = new LocalLlmClient( backendClient = new LocalLlmClient(
@@ -377,7 +381,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
); );
} else { } else {
logger.error( 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.`, `L1/L1.5/L2 disabled.`,
); );
} }