feat(plugins): support provider auth aliases

This commit is contained in:
Peter Steinberger
2026-04-08 19:01:42 +01:00
parent fd9f9b8586
commit 9e4f478f86
31 changed files with 382 additions and 197 deletions

View File

@@ -1,15 +1,40 @@
import { describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveAuthProfileOrder } from "./order.js";
import { markAuthProfileGood } from "./profiles.js";
import { saveAuthProfileStore } from "./store.js";
import type { AuthProfileStore } from "./types.js";
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({
plugins: [
{
id: "fixture-provider",
providerAuthAliases: { "fixture-provider-plan": "fixture-provider" },
},
],
diagnostics: [],
})),
);
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
describe("resolveAuthProfileOrder", () => {
it("accepts base-provider credentials for volcengine-plan auth lookup", () => {
beforeEach(() => {
loadPluginManifestRegistry.mockClear();
});
it("accepts aliased provider credentials from manifest metadata", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"volcengine:default": {
"fixture-provider:default": {
type: "api_key",
provider: "volcengine",
provider: "fixture-provider",
key: "sk-test",
},
},
@@ -17,9 +42,39 @@ describe("resolveAuthProfileOrder", () => {
const order = resolveAuthProfileOrder({
store,
provider: "volcengine-plan",
provider: "fixture-provider-plan",
});
expect(order).toEqual(["volcengine:default"]);
expect(order).toEqual(["fixture-provider:default"]);
});
it("marks aliased provider profiles good under the canonical auth provider", async () => {
const agentDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-auth-profile-alias-"));
try {
const store: AuthProfileStore = {
version: 1,
profiles: {
"fixture-provider:default": {
type: "api_key",
provider: "fixture-provider",
key: "sk-test",
},
},
};
saveAuthProfileStore(store, agentDir);
await markAuthProfileGood({
store,
provider: "fixture-provider-plan",
profileId: "fixture-provider:default",
agentDir,
});
expect(store.lastGood).toEqual({
"fixture-provider": "fixture-provider:default",
});
} finally {
await rm(agentDir, { force: true, recursive: true });
}
});
});

View File

@@ -1,9 +1,6 @@
import type { OpenClawConfig } from "../../config/config.js";
import {
findNormalizedProviderValue,
normalizeProviderId,
normalizeProviderIdForAuth,
} from "../model-selection.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import {
evaluateStoredCredentialEligibility,
type AuthCredentialReasonCode,
@@ -34,17 +31,19 @@ export function resolveAuthProfileEligibility(params: {
profileId: string;
now?: number;
}): AuthProfileEligibility {
const providerAuthKey = normalizeProviderIdForAuth(params.provider);
const providerAuthKey = resolveProviderIdForAuth(params.provider, { config: params.cfg });
const cred = params.store.profiles[params.profileId];
if (!cred) {
return { eligible: false, reasonCode: "profile_missing" };
}
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
if (resolveProviderIdForAuth(cred.provider, { config: params.cfg }) !== providerAuthKey) {
return { eligible: false, reasonCode: "provider_mismatch" };
}
const profileConfig = params.cfg?.auth?.profiles?.[params.profileId];
if (profileConfig) {
if (normalizeProviderIdForAuth(profileConfig.provider) !== providerAuthKey) {
if (
resolveProviderIdForAuth(profileConfig.provider, { config: params.cfg }) !== providerAuthKey
) {
return { eligible: false, reasonCode: "provider_mismatch" };
}
if (profileConfig.mode !== cred.type) {
@@ -72,7 +71,7 @@ export function resolveAuthProfileOrder(params: {
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const providerAuthKey = normalizeProviderIdForAuth(provider);
const providerAuthKey = resolveProviderIdForAuth(provider, { config: cfg });
const now = Date.now();
// Clear any cooldowns that have expired since the last check so profiles
@@ -84,7 +83,10 @@ export function resolveAuthProfileOrder(params: {
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === providerAuthKey)
.filter(
([, profile]) =>
resolveProviderIdForAuth(profile.provider, { config: cfg }) === providerAuthKey,
)
.map(([profileId]) => profileId)
: [];
const baseOrder =
@@ -98,7 +100,7 @@ export function resolveAuthProfileOrder(params: {
resolveAuthProfileEligibility({
cfg,
store,
provider: providerAuthKey,
provider,
profileId,
now,
}).eligible;

View File

@@ -1,6 +1,7 @@
import { normalizeStringEntries } from "../../shared/string-normalization.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { normalizeProviderId, normalizeProviderIdForAuth } from "../provider-id.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { normalizeProviderId } from "../provider-id.js";
import {
ensureAuthProfileStore,
saveAuthProfileStore,
@@ -78,9 +79,9 @@ export async function upsertAuthProfileWithLock(params: {
}
export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
const providerKey = normalizeProviderIdForAuth(provider);
const providerKey = resolveProviderIdForAuth(provider);
return Object.entries(store.profiles)
.filter(([, cred]) => normalizeProviderIdForAuth(cred.provider) === providerKey)
.filter(([, cred]) => resolveProviderIdForAuth(cred.provider) === providerKey)
.map(([id]) => id);
}
@@ -91,14 +92,15 @@ export async function markAuthProfileGood(params: {
agentDir?: string;
}): Promise<void> {
const { store, provider, profileId, agentDir } = params;
const providerKey = resolveProviderIdForAuth(provider);
const updated = await updateAuthProfileStoreWithLock({
agentDir,
updater: (freshStore) => {
const profile = freshStore.profiles[profileId];
if (!profile || profile.provider !== provider) {
if (!profile || resolveProviderIdForAuth(profile.provider) !== providerKey) {
return false;
}
freshStore.lastGood = { ...freshStore.lastGood, [provider]: profileId };
freshStore.lastGood = { ...freshStore.lastGood, [providerKey]: profileId };
return true;
},
});
@@ -107,9 +109,9 @@ export async function markAuthProfileGood(params: {
return;
}
const profile = store.profiles[profileId];
if (!profile || profile.provider !== provider) {
if (!profile || resolveProviderIdForAuth(profile.provider) !== providerKey) {
return;
}
store.lastGood = { ...store.lastGood, [provider]: profileId };
store.lastGood = { ...store.lastGood, [providerKey]: profileId };
saveAuthProfileStore(store, agentDir);
}

View File

@@ -2,9 +2,12 @@ import {
listKnownProviderAuthEnvVarNames,
resolveProviderAuthEnvVarCandidates,
} from "../secrets/provider-env-vars.js";
import type { ProviderEnvVarLookupParams } from "../secrets/provider-env-vars.js";
export function resolveProviderEnvApiKeyCandidates(): Record<string, readonly string[]> {
return resolveProviderAuthEnvVarCandidates();
export function resolveProviderEnvApiKeyCandidates(
params?: ProviderEnvVarLookupParams,
): Record<string, readonly string[]> {
return resolveProviderAuthEnvVarCandidates(params);
}
export const PROVIDER_ENV_API_KEY_CANDIDATES = resolveProviderEnvApiKeyCandidates();

View File

@@ -4,7 +4,7 @@ import { resolvePluginSetupProvider } from "../plugins/setup-registry.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js";
import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js";
import { normalizeProviderIdForAuth } from "./provider-id.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
export type EnvApiKeyResult = {
apiKey: string;
@@ -15,8 +15,8 @@ export function resolveEnvApiKey(
provider: string,
env: NodeJS.ProcessEnv = process.env,
): EnvApiKeyResult | null {
const normalized = normalizeProviderIdForAuth(provider);
const candidateMap = resolveProviderEnvApiKeyCandidates();
const normalized = resolveProviderIdForAuth(provider, { env });
const candidateMap = resolveProviderEnvApiKeyCandidates({ env });
const applied = new Set(getShellEnvAppliedKeys());
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = normalizeOptionalSecretInput(env[envVar]);

View File

@@ -813,19 +813,6 @@ describe("getApiKeyForModel", () => {
);
});
it("resolveEnvApiKey('volcengine-plan') uses volcengine auth candidates", async () => {
await withEnvAsync(
{
VOLCANO_ENGINE_API_KEY: "volcengine-plan-key",
},
async () => {
const resolved = resolveEnvApiKey("volcengine-plan");
expect(resolved?.apiKey).toBe("volcengine-plan-key");
expect(resolved?.source).toContain("VOLCANO_ENGINE_API_KEY");
},
);
});
it("resolveEnvApiKey('anthropic-vertex') uses the provided env snapshot", async () => {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_CLOUD_PROJECT_ID: "vertex-project",

View File

@@ -127,9 +127,9 @@ describe("model-selection", () => {
});
describe("normalizeProviderIdForAuth", () => {
it("maps coding-plan variants to base provider for auth lookup", () => {
expect(normalizeProviderIdForAuth("volcengine-plan")).toBe("volcengine");
expect(normalizeProviderIdForAuth("byteplus-plan")).toBe("byteplus");
it("only applies generic provider-id normalization before auth alias lookup", () => {
expect(normalizeProviderIdForAuth("qwencloud")).toBe("qwen");
expect(normalizeProviderIdForAuth("openai-codex")).toBe("openai-codex");
expect(normalizeProviderIdForAuth("openai")).toBe("openai");
});
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createProviderAuthResolver } from "./models-config.providers.secrets.js";
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({
plugins: [
{
id: "fixture-provider",
providerAuthEnvVars: {
"fixture-provider": ["FIXTURE_PROVIDER_API_KEY"],
},
providerAuthAliases: {
"fixture-provider-plan": "fixture-provider",
},
},
],
diagnostics: [],
})),
);
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
describe("provider auth aliases", () => {
beforeEach(() => {
loadPluginManifestRegistry.mockClear();
});
it("shares manifest env vars across aliased providers", () => {
const resolveAuth = createProviderAuthResolver(
{
FIXTURE_PROVIDER_API_KEY: "test-key", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
{ version: 1, profiles: {} },
);
expect(resolveAuth("fixture-provider")).toMatchObject({
apiKey: "FIXTURE_PROVIDER_API_KEY",
mode: "api_key",
source: "env",
});
expect(resolveAuth("fixture-provider-plan")).toMatchObject({
apiKey: "FIXTURE_PROVIDER_API_KEY",
mode: "api_key",
source: "env",
});
});
it("reuses env keyRef markers from auth profiles for aliased providers", () => {
const resolveAuth = createProviderAuthResolver({} as NodeJS.ProcessEnv, {
version: 1,
profiles: {
"fixture-provider:default": {
type: "api_key",
provider: "fixture-provider",
keyRef: { source: "env", provider: "default", id: "FIXTURE_PROVIDER_API_KEY" },
},
},
});
expect(resolveAuth("fixture-provider")).toMatchObject({
apiKey: "FIXTURE_PROVIDER_API_KEY",
mode: "api_key",
source: "profile",
profileId: "fixture-provider:default",
});
expect(resolveAuth("fixture-provider-plan")).toMatchObject({
apiKey: "FIXTURE_PROVIDER_API_KEY",
mode: "api_key",
source: "profile",
profileId: "fixture-provider:default",
});
});
});

View File

@@ -13,7 +13,7 @@ import {
resolveNonEnvSecretRefHeaderValueMarker,
} from "./model-auth-markers.js";
import { resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js";
import { normalizeProviderIdForAuth } from "./provider-id.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@@ -325,7 +325,7 @@ export function createProviderApiKeyResolver(
config?: OpenClawConfig,
): ProviderApiKeyResolver {
return (provider: string): { apiKey: string | undefined; discoveryApiKey?: string } => {
const authProvider = normalizeProviderIdForAuth(provider);
const authProvider = resolveProviderIdForAuth(provider, { config, env });
const envVar = resolveEnvApiKeyVarName(authProvider, env);
if (envVar) {
return {
@@ -361,7 +361,7 @@ export function createProviderAuthResolver(
config?: OpenClawConfig,
): ProviderAuthResolver {
return (provider: string, options?: { oauthMarker?: string }) => {
const authProvider = normalizeProviderIdForAuth(provider);
const authProvider = resolveProviderIdForAuth(provider, { config, env });
const ids = listProfilesForProvider(authStore, authProvider);
let oauthCandidate:
| {
@@ -446,7 +446,7 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op
// Providers own any provider-specific fallback auth logic via
// resolveSyntheticAuth(...). Discovery/bootstrap callers may consume
// non-secret markers from source config, but must never persist plaintext.
const authProvider = normalizeProviderIdForAuth(params.provider);
const authProvider = resolveProviderIdForAuth(params.provider, { config: params.config });
const synthetic = resolveProviderSyntheticAuthWithPlugin({
provider: authProvider,
config: params.config,

View File

@@ -1,114 +0,0 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
async function resetProviderRuntimeState() {
const [
{ clearPluginManifestRegistryCache },
{ resetProviderRuntimeHookCacheForTest },
{ resetPluginLoaderTestStateForTest },
] = await Promise.all([
import("../plugins/manifest-registry.js"),
import("../plugins/provider-runtime.js"),
import("../plugins/loader.test-fixtures.js"),
]);
resetPluginLoaderTestStateForTest();
clearPluginManifestRegistryCache();
resetProviderRuntimeHookCacheForTest();
}
let createProviderAuthResolver: typeof import("./models-config.providers.secrets.js").createProviderAuthResolver;
async function loadSecretsModule() {
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../secrets/provider-env-vars.js");
vi.resetModules();
await resetProviderRuntimeState();
({ createProviderAuthResolver } = await import("./models-config.providers.secrets.js"));
}
beforeAll(loadSecretsModule);
describe("Volcengine and BytePlus providers", () => {
it("shares VOLCANO_ENGINE_API_KEY across volcengine auth aliases", () => {
const resolveAuth = createProviderAuthResolver(
{
VOLCANO_ENGINE_API_KEY: "test-key", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
{ version: 1, profiles: {} },
);
expect(resolveAuth("volcengine")).toMatchObject({
apiKey: "VOLCANO_ENGINE_API_KEY",
mode: "api_key",
source: "env",
});
expect(resolveAuth("volcengine-plan")).toMatchObject({
apiKey: "VOLCANO_ENGINE_API_KEY",
mode: "api_key",
source: "env",
});
});
it("shares BYTEPLUS_API_KEY across byteplus auth aliases", () => {
const resolveAuth = createProviderAuthResolver(
{
BYTEPLUS_API_KEY: "test-key", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
{ version: 1, profiles: {} },
);
expect(resolveAuth("byteplus")).toMatchObject({
apiKey: "BYTEPLUS_API_KEY",
mode: "api_key",
source: "env",
});
expect(resolveAuth("byteplus-plan")).toMatchObject({
apiKey: "BYTEPLUS_API_KEY",
mode: "api_key",
source: "env",
});
});
it("reuses env keyRef markers from auth profiles for paired providers", () => {
const resolveAuth = createProviderAuthResolver({} as NodeJS.ProcessEnv, {
version: 1,
profiles: {
"volcengine:default": {
type: "api_key",
provider: "volcengine",
keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" },
},
"byteplus:default": {
type: "api_key",
provider: "byteplus",
keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" },
},
},
});
expect(resolveAuth("volcengine")).toMatchObject({
apiKey: "VOLCANO_ENGINE_API_KEY",
mode: "api_key",
source: "profile",
profileId: "volcengine:default",
});
expect(resolveAuth("volcengine-plan")).toMatchObject({
apiKey: "VOLCANO_ENGINE_API_KEY",
mode: "api_key",
source: "profile",
profileId: "volcengine:default",
});
expect(resolveAuth("byteplus")).toMatchObject({
apiKey: "BYTEPLUS_API_KEY",
mode: "api_key",
source: "profile",
profileId: "byteplus:default",
});
expect(resolveAuth("byteplus-plan")).toMatchObject({
apiKey: "BYTEPLUS_API_KEY",
mode: "api_key",
source: "profile",
profileId: "byteplus:default",
});
});
});

View File

@@ -0,0 +1,43 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { normalizeProviderId } from "./provider-id.js";
export type ProviderAuthAliasLookupParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
export function resolveProviderAuthAliasMap(
params?: ProviderAuthAliasLookupParams,
): Record<string, string> {
const registry = loadPluginManifestRegistry({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
});
const aliases: Record<string, string> = Object.create(null) as Record<string, string>;
for (const plugin of registry.plugins) {
for (const [alias, target] of Object.entries(plugin.providerAuthAliases ?? {}).toSorted(
([left], [right]) => left.localeCompare(right),
)) {
const normalizedAlias = normalizeProviderId(alias);
const normalizedTarget = normalizeProviderId(target);
if (normalizedAlias && normalizedTarget) {
aliases[normalizedAlias] = normalizedTarget;
}
}
}
return aliases;
}
export function resolveProviderIdForAuth(
provider: string,
params?: ProviderAuthAliasLookupParams,
): string {
const normalized = normalizeProviderId(provider);
if (!normalized) {
return normalized;
}
return resolveProviderAuthAliasMap(params)[normalized] ?? normalized;
}

View File

@@ -27,16 +27,9 @@ export function normalizeProviderId(provider: string): string {
return normalized;
}
/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */
/** Normalize provider ID before manifest-owned auth alias lookup. */
export function normalizeProviderIdForAuth(provider: string): string {
const normalized = normalizeProviderId(provider);
if (normalized === "volcengine-plan") {
return "volcengine";
}
if (normalized === "byteplus-plan") {
return "byteplus";
}
return normalized;
return normalizeProviderId(provider);
}
export function findNormalizedProviderValue<T>(

View File

@@ -2,9 +2,9 @@ import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
import {
type ModelAliasIndex,
modelKey,
normalizeProviderIdForAuth,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveProfileOverride } from "./directive-handling.auth-profile.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
@@ -84,8 +84,12 @@ export function resolveModelSelectionFromDirective(params: {
: null;
const useStoredNumericProfile =
Boolean(storedNumericProfileSelection?.selection) &&
normalizeProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "") ===
normalizeProviderIdForAuth(storedNumericProfile?.profileProvider ?? "");
resolveProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "", {
config: params.cfg,
}) ===
resolveProviderIdForAuth(storedNumericProfile?.profileProvider ?? "", {
config: params.cfg,
});
const modelRaw =
useStoredNumericProfile && storedNumericProfile ? storedNumericProfile.modelRaw : raw;
let modelSelection: ModelDirectiveSelection | undefined;

View File

@@ -59,6 +59,7 @@ describe("resolvePluginConfigContractsById", () => {
modelSupport: undefined,
cliBackends: [],
channelEnvVars: undefined,
providerAuthAliases: undefined,
providerAuthChoices: undefined,
skills: [],
settingsFiles: undefined,

View File

@@ -382,6 +382,9 @@ describe("loadPluginManifestRegistry", () => {
providerAuthEnvVars: {
openai: ["OPENAI_API_KEY"],
},
providerAuthAliases: {
"openai-codex": "openai",
},
providerAuthChoices: [
{
provider: "openai",
@@ -404,6 +407,9 @@ describe("loadPluginManifestRegistry", () => {
expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({
openai: ["OPENAI_API_KEY"],
});
expect(registry.plugins[0]?.providerAuthAliases).toEqual({
"openai-codex": "openai",
});
expect(registry.plugins[0]?.enabledByDefault).toBe(true);
expect(registry.plugins[0]?.providerAuthChoices).toEqual([
{

View File

@@ -78,6 +78,7 @@ export type PluginManifestRecord = {
modelSupport?: PluginManifestModelSupport;
cliBackends: string[];
providerAuthEnvVars?: Record<string, string[]>;
providerAuthAliases?: Record<string, string>;
channelEnvVars?: Record<string, string[]>;
providerAuthChoices?: PluginManifest["providerAuthChoices"];
skills: string[];
@@ -311,6 +312,7 @@ function buildRecord(params: {
modelSupport: params.manifest.modelSupport,
cliBackends: params.manifest.cliBackends ?? [],
providerAuthEnvVars: params.manifest.providerAuthEnvVars,
providerAuthAliases: params.manifest.providerAuthAliases,
channelEnvVars: params.manifest.channelEnvVars,
providerAuthChoices: params.manifest.providerAuthChoices,
skills: params.manifest.skills ?? [],

View File

@@ -105,6 +105,8 @@ export type PluginManifest = {
cliBackends?: string[];
/** Cheap provider-auth env lookup without booting plugin runtime. */
providerAuthEnvVars?: Record<string, string[]>;
/** Provider ids that should reuse another provider id for auth lookup. */
providerAuthAliases?: Record<string, string>;
/** Cheap channel env lookup without booting plugin runtime. */
channelEnvVars?: Record<string, string[]>;
/**
@@ -198,6 +200,22 @@ function normalizeStringListRecord(value: unknown): Record<string, string[]> | u
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeOptionalString(rawKey) ?? "";
const value = normalizeOptionalString(rawValue) ?? "";
if (!key || !value) {
continue;
}
normalized[key] = value;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeManifestContracts(value: unknown): PluginManifestContracts | undefined {
if (!isRecord(value)) {
return undefined;
@@ -516,6 +534,7 @@ export function loadPluginManifest(
const modelSupport = normalizeManifestModelSupport(raw.modelSupport);
const cliBackends = normalizeTrimmedStringList(raw.cliBackends);
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
const providerAuthAliases = normalizeStringRecord(raw.providerAuthAliases);
const channelEnvVars = normalizeStringListRecord(raw.channelEnvVars);
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
const skills = normalizeTrimmedStringList(raw.skills);
@@ -544,6 +563,7 @@ export function loadPluginManifest(
modelSupport,
cliBackends,
providerAuthEnvVars,
providerAuthAliases,
channelEnvVars,
providerAuthChoices,
skills,

View File

@@ -8,6 +8,7 @@ vi.mock("./manifest-registry.js", () => ({
import {
resolveManifestDeprecatedProviderAuthChoice,
resolveManifestProviderApiKeyChoice,
resolveManifestProviderAuthChoice,
resolveManifestProviderAuthChoices,
resolveManifestProviderOnboardAuthFlags,
@@ -338,4 +339,33 @@ describe("provider auth choice manifest helpers", () => {
},
]);
});
it("resolves api-key choices through manifest-owned provider auth aliases", () => {
setManifestPlugins([
{
id: "fixture-provider",
origin: "bundled",
providerAuthAliases: {
"fixture-provider-plan": "fixture-provider",
},
providerAuthChoices: [
{
provider: "fixture-provider",
method: "api-key",
choiceId: "fixture-provider-api-key",
choiceLabel: "Fixture Provider API key",
optionKey: "fixtureProviderApiKey",
cliFlag: "--fixture-provider-api-key",
cliOption: "--fixture-provider-api-key <key>",
},
],
},
]);
expect(
resolveManifestProviderApiKeyChoice({
providerId: "fixture-provider-plan",
})?.choiceId,
).toBe("fixture-provider-api-key");
});
});

View File

@@ -1,4 +1,4 @@
import { normalizeProviderIdForAuth } from "../agents/model-selection.js";
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
@@ -203,7 +203,7 @@ export function resolveManifestProviderApiKeyChoice(params: {
env?: NodeJS.ProcessEnv;
includeUntrustedWorkspacePlugins?: boolean;
}): ProviderAuthChoiceMetadata | undefined {
const normalizedProviderId = normalizeProviderIdForAuth(params.providerId);
const normalizedProviderId = resolveProviderIdForAuth(params.providerId, params);
if (!normalizedProviderId) {
return undefined;
}
@@ -211,7 +211,7 @@ export function resolveManifestProviderApiKeyChoice(params: {
if (!choice.optionKey) {
return false;
}
return normalizeProviderIdForAuth(choice.providerId) === normalizedProviderId;
return resolveProviderIdForAuth(choice.providerId, params) === normalizedProviderId;
});
const preferred = pickPreferredManifestAuthChoice(candidates);
return preferred ? stripChoiceOrigin(preferred) : undefined;

View File

@@ -4,7 +4,7 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { buildAuthProfileId } from "../agents/auth-profiles/identity.js";
import { upsertAuthProfile } from "../agents/auth-profiles/profiles.js";
import { normalizeProviderIdForAuth } from "../agents/provider-id.js";
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
@@ -136,7 +136,7 @@ export function applyAuthProfileConfig(
preferProfileFirst?: boolean;
},
): OpenClawConfig {
const normalizedProvider = normalizeProviderIdForAuth(params.provider);
const normalizedProvider = resolveProviderIdForAuth(params.provider, { config: cfg });
const profiles = {
...cfg.auth?.profiles,
[params.profileId]: {
@@ -148,13 +148,16 @@ export function applyAuthProfileConfig(
};
const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {})
.filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === normalizedProvider)
.filter(
([, profile]) =>
resolveProviderIdForAuth(profile.provider, { config: cfg }) === normalizedProvider,
)
.map(([profileId, profile]) => ({ profileId, mode: profile.mode }));
// Maintain `auth.order` when it already exists. Additionally, if we detect
// mixed auth modes for the same provider, keep the newly selected profile first.
const matchingProviderOrderEntries = Object.entries(cfg.auth?.order ?? {}).filter(
([providerId]) => normalizeProviderIdForAuth(providerId) === normalizedProvider,
([providerId]) => resolveProviderIdForAuth(providerId, { config: cfg }) === normalizedProvider,
);
const existingProviderOrder =
matchingProviderOrderEntries.length > 0
@@ -184,7 +187,8 @@ export function applyAuthProfileConfig(
matchingProviderOrderEntries.length > 0
? Object.fromEntries(
Object.entries(cfg.auth?.order ?? {}).filter(
([providerId]) => normalizeProviderIdForAuth(providerId) !== normalizedProvider,
([providerId]) =>
resolveProviderIdForAuth(providerId, { config: cfg }) !== normalizedProvider,
),
)
: cfg.auth?.order;

View File

@@ -5,6 +5,7 @@ type MockManifestRegistry = {
id: string;
origin: string;
providerAuthEnvVars?: Record<string, string[]>;
providerAuthAliases?: Record<string, string>;
}>;
diagnostics: unknown[];
};
@@ -33,6 +34,9 @@ describe("provider env vars dynamic manifest metadata", () => {
providerAuthEnvVars: {
fireworks: ["FIREWORKS_ALT_API_KEY"],
},
providerAuthAliases: {
"fireworks-plan": "fireworks",
},
},
],
diagnostics: [],
@@ -41,6 +45,7 @@ describe("provider env vars dynamic manifest metadata", () => {
const mod = await import("./provider-env-vars.js");
expect(mod.getProviderEnvVars("fireworks")).toEqual(["FIREWORKS_ALT_API_KEY"]);
expect(mod.getProviderEnvVars("fireworks-plan")).toEqual(["FIREWORKS_ALT_API_KEY"]);
expect(mod.listKnownProviderAuthEnvVarNames()).toContain("FIREWORKS_ALT_API_KEY");
expect(mod.listKnownSecretEnvVarNames()).toContain("FIREWORKS_ALT_API_KEY");
});

View File

@@ -1,3 +1,4 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
@@ -12,7 +13,7 @@ const CORE_PROVIDER_SETUP_ENV_VAR_OVERRIDES = {
"minimax-cn": ["MINIMAX_API_KEY"],
} as const;
type ProviderEnvVarLookupParams = {
export type ProviderEnvVarLookupParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
@@ -58,6 +59,26 @@ function resolveManifestProviderAuthEnvVarCandidates(
appendUniqueEnvVarCandidates(candidates, providerId, keys);
}
}
const aliases: Record<string, string> = Object.create(null) as Record<string, string>;
for (const plugin of registry.plugins) {
for (const [alias, target] of Object.entries(plugin.providerAuthAliases ?? {}).toSorted(
([left], [right]) => left.localeCompare(right),
)) {
const normalizedAlias = normalizeProviderId(alias);
const normalizedTarget = normalizeProviderId(target);
if (normalizedAlias && normalizedTarget) {
aliases[normalizedAlias] = normalizedTarget;
}
}
}
for (const [alias, target] of Object.entries(aliases).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
const keys = candidates[target];
if (keys) {
appendUniqueEnvVarCandidates(candidates, alias, keys);
}
}
return candidates;
}