refactor(plugins): move auth profile hooks into providers

This commit is contained in:
Peter Steinberger
2026-03-15 22:23:19 -07:00
parent abe7ea4373
commit e627a5069f
33 changed files with 712 additions and 693 deletions

View File

@@ -1,4 +1,7 @@
export {
augmentModelCatalogWithProviderPlugins,
buildProviderAuthDoctorHintWithPlugin,
buildProviderMissingAuthMessageWithPlugin,
formatProviderAuthProfileApiKeyWithPlugin,
refreshProviderOAuthCredentialWithPlugin,
} from "./provider-runtime.js";

View File

@@ -14,7 +14,9 @@ vi.mock("./providers.js", () => ({
import {
augmentModelCatalogWithProviderPlugins,
buildProviderAuthDoctorHintWithPlugin,
buildProviderMissingAuthMessageWithPlugin,
formatProviderAuthProfileApiKeyWithPlugin,
prepareProviderExtraParams,
resolveProviderCacheTtlEligibility,
resolveProviderBinaryThinking,
@@ -28,6 +30,7 @@ import {
normalizeProviderResolvedModelWithPlugin,
prepareProviderDynamicModel,
prepareProviderRuntimeAuth,
refreshProviderOAuthCredentialWithPlugin,
resolveProviderRuntimePlugin,
runProviderDynamicModel,
wrapProviderStreamFn,
@@ -87,6 +90,10 @@ describe("provider-runtime", () => {
baseUrl: "https://runtime.example.com/v1",
expiresAt: 123,
}));
const refreshOAuth = vi.fn(async (cred) => ({
...cred,
access: "refreshed-access-token",
}));
const resolveUsageAuth = vi.fn(async () => ({
token: "usage-token",
accountId: "usage-account",
@@ -96,34 +103,7 @@ describe("provider-runtime", () => {
displayName: "Demo",
windows: [{ label: "Day", usedPercent: 25 }],
}));
resolvePluginProvidersMock.mockImplementation((params: unknown) => {
const scopedParams = params as { onlyPluginIds?: string[] } | undefined;
if (scopedParams?.onlyPluginIds?.includes("openai")) {
return [
{
id: "openai",
label: "OpenAI",
auth: [],
buildMissingAuthMessage: () =>
'No API key found for provider "openai". Use openai-codex/gpt-5.4.',
suppressBuiltInModel: ({ provider, modelId }) =>
provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark"
? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" }
: undefined,
augmentModelCatalog: () => [
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
{ provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" },
{
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
name: "gpt-5.3-codex-spark",
},
],
},
];
}
resolvePluginProvidersMock.mockImplementation((_params: unknown) => {
return [
{
id: "demo",
@@ -143,6 +123,11 @@ describe("provider-runtime", () => {
...model,
api: "openai-codex-responses",
}),
formatApiKey: (cred) =>
cred.type === "oauth" ? JSON.stringify({ token: cred.access }) : "",
refreshOAuth,
buildAuthDoctorHint: ({ provider, profileId }) =>
provider === "demo" ? `Repair ${profileId}` : undefined,
prepareRuntimeAuth,
resolveUsageAuth,
fetchUsageSnapshot,
@@ -152,6 +137,27 @@ describe("provider-runtime", () => {
resolveDefaultThinkingLevel: ({ reasoning }) => (reasoning ? "low" : "off"),
isModernModelRef: ({ modelId }) => modelId.startsWith("gpt-5"),
},
{
id: "openai",
label: "OpenAI",
auth: [],
buildMissingAuthMessage: () =>
'No API key found for provider "openai". Use openai-codex/gpt-5.4.',
suppressBuiltInModel: ({ provider, modelId }) =>
provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark"
? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" }
: undefined,
augmentModelCatalog: () => [
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
{ provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" },
{
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
name: "gpt-5.3-codex-spark",
},
],
},
];
});
@@ -241,6 +247,45 @@ describe("provider-runtime", () => {
expiresAt: 123,
});
expect(
formatProviderAuthProfileApiKeyWithPlugin({
provider: "demo",
context: {
type: "oauth",
provider: "demo",
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
}),
).toBe('{"token":"oauth-access"}');
await expect(
refreshProviderOAuthCredentialWithPlugin({
provider: "demo",
context: {
type: "oauth",
provider: "demo",
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
}),
).resolves.toMatchObject({
access: "refreshed-access-token",
});
await expect(
buildProviderAuthDoctorHintWithPlugin({
provider: "demo",
context: {
provider: "demo",
profileId: "demo:default",
store: { version: 1, profiles: {} },
},
}),
).resolves.toBe("Repair demo:default");
await expect(
resolveProviderUsageAuthWithPlugin({
provider: "demo",
@@ -376,12 +421,8 @@ describe("provider-runtime", () => {
},
]);
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openai"],
}),
);
expect(prepareDynamicModel).toHaveBeenCalledTimes(1);
expect(refreshOAuth).toHaveBeenCalledTimes(1);
expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1);
expect(resolveUsageAuth).toHaveBeenCalledTimes(1);
expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1);

View File

@@ -1,7 +1,9 @@
import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js";
import type {
ProviderAuthDoctorHintContext,
ProviderAugmentModelCatalogContext,
ProviderBuildMissingAuthMessageContext,
ProviderBuiltInModelSuppressionContext,
@@ -46,19 +48,6 @@ function resolveProviderPluginsForHooks(params: {
});
}
const GLOBAL_PROVIDER_HOOK_PLUGIN_IDS = ["openai"] as const;
function resolveGlobalProviderHookPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
return resolveProviderPluginsForHooks({
...params,
onlyPluginIds: [...GLOBAL_PROVIDER_HOOK_PLUGIN_IDS],
});
}
export function resolveProviderRuntimePlugin(params: {
provider: string;
config?: OpenClawConfig;
@@ -174,6 +163,36 @@ export async function resolveProviderUsageSnapshotWithPlugin(params: {
return await resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot?.(params.context);
}
export function formatProviderAuthProfileApiKeyWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: AuthProfileCredential;
}) {
return resolveProviderRuntimePlugin(params)?.formatApiKey?.(params.context);
}
export async function refreshProviderOAuthCredentialWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: OAuthCredential;
}) {
return await resolveProviderRuntimePlugin(params)?.refreshOAuth?.(params.context);
}
export async function buildProviderAuthDoctorHintWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderAuthDoctorHintContext;
}) {
return await resolveProviderRuntimePlugin(params)?.buildAuthDoctorHint?.(params.context);
}
export function resolveProviderCacheTtlEligibility(params: {
provider: string;
config?: OpenClawConfig;
@@ -231,10 +250,9 @@ export function buildProviderMissingAuthMessageWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderBuildMissingAuthMessageContext;
}) {
const plugin = resolveGlobalProviderHookPlugins(params).find((providerPlugin) =>
matchesProviderId(providerPlugin, params.provider),
return (
resolveProviderRuntimePlugin(params)?.buildMissingAuthMessage?.(params.context) ?? undefined
);
return plugin?.buildMissingAuthMessage?.(params.context) ?? undefined;
}
export function resolveProviderBuiltInModelSuppression(params: {
@@ -243,7 +261,7 @@ export function resolveProviderBuiltInModelSuppression(params: {
env?: NodeJS.ProcessEnv;
context: ProviderBuiltInModelSuppressionContext;
}) {
for (const plugin of resolveGlobalProviderHookPlugins(params)) {
for (const plugin of resolveProviderPluginsForHooks(params)) {
const result = plugin.suppressBuiltInModel?.(params.context);
if (result?.suppress) {
return result;
@@ -259,7 +277,7 @@ export async function augmentModelCatalogWithProviderPlugins(params: {
context: ProviderAugmentModelCatalogContext;
}) {
const supplemental = [] as ProviderAugmentModelCatalogContext["entries"];
for (const plugin of resolveGlobalProviderHookPlugins(params)) {
for (const plugin of resolveProviderPluginsForHooks(params)) {
const next = await plugin.augmentModelCatalog?.(params.context);
if (!next || next.length === 0) {
continue;

View File

@@ -9,6 +9,7 @@ import type {
ApiKeyCredential,
AuthProfileCredential,
OAuthCredential,
AuthProfileStore,
} from "../agents/auth-profiles/types.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { ProviderCapabilities } from "../agents/provider-capabilities.js";
@@ -367,6 +368,20 @@ export type ProviderFetchUsageSnapshotContext = {
fetchFn: typeof fetch;
};
/**
* Provider-owned auth-doctor hint input.
*
* Called when OAuth refresh fails and OpenClaw wants a provider-specific repair
* hint to append to the generic re-auth message. Use this for legacy profile-id
* migrations or other provider-owned auth-store cleanup guidance.
*/
export type ProviderAuthDoctorHintContext = {
config?: OpenClawConfig;
store: AuthProfileStore;
provider: string;
profileId?: string;
};
/**
* Provider-owned extra-param normalization before OpenClaw builds its generic
* stream option wrapper.
@@ -732,8 +747,34 @@ export type ProviderPlugin = {
*/
isModernModelRef?: (ctx: ProviderModernModelPolicyContext) => boolean | undefined;
wizard?: ProviderPluginWizard;
/**
* Provider-owned auth-profile API-key formatter.
*
* OpenClaw uses this when a stored auth profile is already valid and needs to
* be converted into the runtime `apiKey` string expected by the provider. Use
* this for providers whose auth profile stores extra metadata alongside the
* bearer token (for example Gemini CLI's `{ token, projectId }` payload).
*/
formatApiKey?: (cred: AuthProfileCredential) => string;
/**
* Provider-owned OAuth refresh.
*
* OpenClaw calls this before falling back to the shared `pi-ai` OAuth
* refreshers. Use it when the provider has a custom refresh endpoint, or when
* the provider needs custom refresh-failure behavior that should stay out of
* core auth-profile code.
*/
refreshOAuth?: (cred: OAuthCredential) => Promise<OAuthCredential>;
/**
* Provider-owned auth-doctor hint.
*
* Return a multiline repair hint when OAuth refresh fails and the provider
* wants to steer users toward a specific auth-profile migration or recovery
* path. Return nothing to keep OpenClaw's generic error text.
*/
buildAuthDoctorHint?: (
ctx: ProviderAuthDoctorHintContext,
) => string | Promise<string | null | undefined> | null | undefined;
onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise<void>;
};