From b8f12d99b2542b0fc50ca17f431c2bbfe92da2f1 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 7 Apr 2026 19:28:36 -0700 Subject: [PATCH] fix: expose runtime-ready provider auth to plugins (#62753) --- CHANGELOG.md | 1 + src/plugin-sdk/index.ts | 2 + src/plugin-sdk/provider-auth-runtime.test.ts | 8 ++ src/plugin-sdk/provider-auth-runtime.ts | 11 ++ src/plugins/provider-runtime.test.ts | 43 +++++++ src/plugins/runtime/index.test.ts | 3 +- src/plugins/runtime/index.ts | 10 ++ src/plugins/runtime/model-auth-types.ts | 16 +++ .../runtime-model-auth.runtime.test.ts | 118 ++++++++++++++++++ .../runtime/runtime-model-auth.runtime.ts | 55 +++++++- src/plugins/runtime/types-core.ts | 6 + test/helpers/plugins/plugin-runtime-mock.ts | 2 + 12 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 src/plugin-sdk/provider-auth-runtime.test.ts create mode 100644 src/plugins/runtime/model-auth-types.ts create mode 100644 src/plugins/runtime/runtime-model-auth.runtime.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 879a1a13282..b82d98a69b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose memory-artifact and memory-prompt seams so companion plugins and non-legacy context engines can consume active memory state without reaching into internals. Thanks @vincentkoc. - ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc. - Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby. +- Plugins/provider-auth: expose runtime-ready provider auth through `openclaw/plugin-sdk/provider-auth-runtime` so native plugins and context engines can resolve request-ready credentials after provider-owned runtime exchanges like GitHub Copilot device-token-to-bearer flows. (#62753) Thanks @jalehman. ### Fixes diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ed0e7cf0954..077f9fd106a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -50,10 +50,12 @@ export type { PluginLogger, ProviderAuthContext, ProviderAuthResult, + ProviderPreparedRuntimeAuth, ProviderRuntimeModel, RealtimeTranscriptionProviderPlugin, SpeechProviderPlugin, } from "../plugins/types.js"; +export type { ResolvedProviderRuntimeAuth } from "../plugins/runtime/model-auth-types.js"; export type { PluginRuntime, RuntimeLogger, diff --git a/src/plugin-sdk/provider-auth-runtime.test.ts b/src/plugin-sdk/provider-auth-runtime.test.ts new file mode 100644 index 00000000000..e0c89fe58ac --- /dev/null +++ b/src/plugin-sdk/provider-auth-runtime.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import * as providerAuthRuntime from "./provider-auth-runtime.js"; + +describe("plugin-sdk provider-auth-runtime", () => { + it("exports the runtime-ready auth helper", () => { + expect(typeof providerAuthRuntime.getRuntimeAuthForModel).toBe("function"); + }); +}); diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index d9ac777455e..40360e23c15 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -11,8 +11,12 @@ export { resolveAwsSdkEnvVarName, type ResolvedProviderAuth, } from "../agents/model-auth-runtime-shared.js"; +export type { ProviderPreparedRuntimeAuth } from "../plugins/types.js"; +export type { ResolvedProviderRuntimeAuth } from "../plugins/runtime/model-auth-types.js"; type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider; +type GetRuntimeAuthForModel = + typeof import("../plugins/runtime/runtime-model-auth.runtime.js").getRuntimeAuthForModel; type RuntimeModelAuthModule = typeof import("../plugins/runtime/runtime-model-auth.runtime.js"); const RUNTIME_MODEL_AUTH_CANDIDATES = [ "./runtime-model-auth.runtime", @@ -43,3 +47,10 @@ export async function resolveApiKeyForProvider( const { resolveApiKeyForProvider } = await loadRuntimeModelAuthModule(); return resolveApiKeyForProvider(params); } + +export async function getRuntimeAuthForModel( + params: Parameters[0], +): Promise>> { + const { getRuntimeAuthForModel } = await loadRuntimeModelAuthModule(); + return getRuntimeAuthForModel(params); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 7510f18c65d..59bd2f2d488 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -330,6 +330,49 @@ describe("provider-runtime", () => { }); }); + it("returns provider-prepared runtime auth for the matched provider", async () => { + const prepareRuntimeAuth = vi.fn(async () => ({ + apiKey: "runtime-token", + baseUrl: "https://runtime.example.com/v1", + expiresAt: 123, + })); + resolvePluginProvidersMock.mockReturnValue([ + { + id: DEMO_PROVIDER_ID, + label: "Demo", + auth: [], + prepareRuntimeAuth, + }, + ]); + + await expect( + prepareProviderRuntimeAuth({ + provider: DEMO_PROVIDER_ID, + context: { + config: undefined, + workspaceDir: "/tmp/demo-workspace", + env: process.env, + provider: DEMO_PROVIDER_ID, + modelId: MODEL.id, + model: MODEL, + apiKey: "raw-token", + authMode: "token", + }, + }), + ).resolves.toEqual({ + apiKey: "runtime-token", + baseUrl: "https://runtime.example.com/v1", + expiresAt: 123, + }); + expect(prepareRuntimeAuth).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "raw-token", + modelId: MODEL.id, + provider: DEMO_PROVIDER_ID, + }), + ); + }); + it("returns no runtime plugin when the provider has no owning plugin", () => { expectProviderRuntimePluginLoad({ provider: "anthropic", diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index eaf3a8de00d..5e2808e6368 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -224,11 +224,12 @@ describe("plugin runtime command execution", () => { }, }, { - name: "exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", + name: "exposes runtime.modelAuth with raw and runtime-ready auth helpers", assert: (runtime: ReturnType) => { expect(runtime.modelAuth).toBeDefined(); expectFunctionKeys(runtime.modelAuth as Record, [ "getApiKeyForModel", + "getRuntimeAuthForModel", "resolveApiKeyForProvider", ]); }, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index ae62a2d25ab..cea7adb95fd 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -89,6 +89,10 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] { loadModelAuthRuntime, (runtime) => runtime.getApiKeyForModel, ); + const getRuntimeAuthForModel = createLazyRuntimeMethod( + loadModelAuthRuntime, + (runtime) => runtime.getRuntimeAuthForModel, + ); const resolveApiKeyForProvider = createLazyRuntimeMethod( loadModelAuthRuntime, (runtime) => runtime.resolveApiKeyForProvider, @@ -99,6 +103,12 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] { model: params.model, cfg: params.cfg, }), + getRuntimeAuthForModel: (params) => + getRuntimeAuthForModel({ + model: params.model, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + }), resolveApiKeyForProvider: (params) => resolveApiKeyForProvider({ provider: params.provider, diff --git a/src/plugins/runtime/model-auth-types.ts b/src/plugins/runtime/model-auth-types.ts new file mode 100644 index 00000000000..d75aa9ff8b6 --- /dev/null +++ b/src/plugins/runtime/model-auth-types.ts @@ -0,0 +1,16 @@ +import type { ResolvedProviderAuth } from "../../agents/model-auth-runtime-shared.js"; +import type { ProviderRequestTransportOverrides } from "../../agents/provider-request-config.js"; + +/** + * Runtime-ready auth result exposed to native plugins and context engines. + * + * `source`, `mode`, and `profileId` describe how the original credential was + * resolved. `apiKey` is the request-ready credential after any provider-owned + * runtime exchange, so it may differ from the stored/raw credential. + */ +export type ResolvedProviderRuntimeAuth = Omit & { + apiKey?: string; + baseUrl?: string; + request?: ProviderRequestTransportOverrides; + expiresAt?: number; +}; diff --git a/src/plugins/runtime/runtime-model-auth.runtime.test.ts b/src/plugins/runtime/runtime-model-auth.runtime.test.ts new file mode 100644 index 00000000000..9ecbf68d5fa --- /dev/null +++ b/src/plugins/runtime/runtime-model-auth.runtime.test.ts @@ -0,0 +1,118 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + getApiKeyForModel: vi.fn(), + resolveApiKeyForProvider: vi.fn(), + prepareProviderRuntimeAuth: vi.fn(), +})); + +vi.mock("../../agents/model-auth.js", () => ({ + getApiKeyForModel: hoisted.getApiKeyForModel, + resolveApiKeyForProvider: hoisted.resolveApiKeyForProvider, +})); + +vi.mock("../provider-runtime.runtime.js", () => ({ + prepareProviderRuntimeAuth: hoisted.prepareProviderRuntimeAuth, +})); + +let getRuntimeAuthForModel: typeof import("./runtime-model-auth.runtime.js").getRuntimeAuthForModel; + +const MODEL = { + id: "github-copilot/gpt-4o", + provider: "github-copilot", + api: "openai-responses", + baseUrl: "https://api.githubcopilot.com", +}; + +describe("runtime-model-auth.runtime", () => { + beforeAll(async () => { + ({ getRuntimeAuthForModel } = await import("./runtime-model-auth.runtime.js")); + }); + + beforeEach(() => { + hoisted.getApiKeyForModel.mockReset(); + hoisted.resolveApiKeyForProvider.mockReset(); + hoisted.prepareProviderRuntimeAuth.mockReset(); + }); + + it("returns provider-prepared runtime auth when the provider transforms credentials", async () => { + hoisted.getApiKeyForModel.mockResolvedValue({ + apiKey: "github-device-token", + source: "profile:github-copilot:github", + mode: "token", + profileId: "github-copilot:github", + }); + hoisted.prepareProviderRuntimeAuth.mockResolvedValue({ + apiKey: "copilot-bearer-token", + baseUrl: "https://api.individual.githubcopilot.com", + expiresAt: 123, + }); + + await expect( + getRuntimeAuthForModel({ + model: MODEL as never, + }), + ).resolves.toEqual({ + apiKey: "copilot-bearer-token", + source: "profile:github-copilot:github", + mode: "token", + profileId: "github-copilot:github", + baseUrl: "https://api.individual.githubcopilot.com", + expiresAt: 123, + }); + expect(hoisted.prepareProviderRuntimeAuth).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + context: expect.objectContaining({ + apiKey: "github-device-token", + modelId: "github-copilot/gpt-4o", + provider: "github-copilot", + }), + }), + ); + }); + + it("falls back to raw auth when the provider has no runtime auth hook", async () => { + hoisted.getApiKeyForModel.mockResolvedValue({ + apiKey: "plain-api-key", + source: "env:OPENAI_API_KEY", + mode: "api-key", + }); + hoisted.prepareProviderRuntimeAuth.mockResolvedValue(undefined); + + await expect( + getRuntimeAuthForModel({ + model: { + ...MODEL, + id: "openai/gpt-5.4", + provider: "openai", + } as never, + }), + ).resolves.toEqual({ + apiKey: "plain-api-key", + source: "env:OPENAI_API_KEY", + mode: "api-key", + }); + }); + + it("skips provider preparation when raw auth does not expose an apiKey", async () => { + hoisted.getApiKeyForModel.mockResolvedValue({ + source: "env:AWS_PROFILE", + mode: "aws-sdk", + }); + + await expect( + getRuntimeAuthForModel({ + model: { + ...MODEL, + id: "bedrock/claude-sonnet", + provider: "bedrock", + } as never, + }), + ).resolves.toEqual({ + source: "env:AWS_PROFILE", + mode: "aws-sdk", + }); + expect(hoisted.prepareProviderRuntimeAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/runtime/runtime-model-auth.runtime.ts b/src/plugins/runtime/runtime-model-auth.runtime.ts index c43526ecae2..b11c3afa5dc 100644 --- a/src/plugins/runtime/runtime-model-auth.runtime.ts +++ b/src/plugins/runtime/runtime-model-auth.runtime.ts @@ -1 +1,54 @@ -export { getApiKeyForModel, resolveApiKeyForProvider } from "../../agents/model-auth.js"; +import type { Api, Model } from "@mariozechner/pi-ai"; +import { getApiKeyForModel, resolveApiKeyForProvider } from "../../agents/model-auth.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { prepareProviderRuntimeAuth } from "../provider-runtime.runtime.js"; +import type { ResolvedProviderRuntimeAuth } from "./model-auth-types.js"; + +export { getApiKeyForModel, resolveApiKeyForProvider }; + +/** + * Resolve request-ready auth for a runtime model, applying any provider-owned + * `prepareRuntimeAuth` exchange on top of the standard credential lookup. + */ +export async function getRuntimeAuthForModel(params: { + model: Model; + cfg?: OpenClawConfig; + workspaceDir?: string; +}): Promise { + const resolvedAuth = await getApiKeyForModel({ + model: params.model, + cfg: params.cfg, + }); + + if (!resolvedAuth.apiKey || resolvedAuth.mode === "aws-sdk") { + return resolvedAuth; + } + + const preparedAuth = await prepareProviderRuntimeAuth({ + provider: params.model.provider, + config: params.cfg, + workspaceDir: params.workspaceDir, + env: process.env, + context: { + config: params.cfg, + workspaceDir: params.workspaceDir, + env: process.env, + provider: params.model.provider, + modelId: params.model.id, + model: params.model, + apiKey: resolvedAuth.apiKey, + authMode: resolvedAuth.mode, + profileId: resolvedAuth.profileId, + }, + }); + + if (!preparedAuth) { + return resolvedAuth; + } + + return { + ...resolvedAuth, + ...preparedAuth, + apiKey: preparedAuth.apiKey ?? resolvedAuth.apiKey, + }; +} diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 48c86a5795e..f8b528d8ef7 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -125,6 +125,12 @@ export type PluginRuntimeCore = { model: import("@mariozechner/pi-ai").Model; cfg?: import("../../config/config.js").OpenClawConfig; }) => Promise; + /** Resolve request-ready auth for a model, including provider runtime exchanges. */ + getRuntimeAuthForModel: (params: { + model: import("@mariozechner/pi-ai").Model; + cfg?: import("../../config/config.js").OpenClawConfig; + workspaceDir?: string; + }) => Promise; /** Resolve auth for a provider by name. Only provider and optional cfg are used. */ resolveApiKeyForProvider: (params: { provider: string; diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index 664158dd4c9..604ddff88fc 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -395,6 +395,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = taskFlow, modelAuth: { getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"], + getRuntimeAuthForModel: + vi.fn() as unknown as PluginRuntime["modelAuth"]["getRuntimeAuthForModel"], resolveApiKeyForProvider: vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"], },