fix: expose runtime-ready provider auth to plugins (#62753)

This commit is contained in:
Josh Lehman
2026-04-07 19:28:36 -07:00
committed by GitHub
parent 5050017543
commit b8f12d99b2
12 changed files with 273 additions and 2 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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<GetRuntimeAuthForModel>[0],
): Promise<Awaited<ReturnType<GetRuntimeAuthForModel>>> {
const { getRuntimeAuthForModel } = await loadRuntimeModelAuthModule();
return getRuntimeAuthForModel(params);
}

View File

@@ -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",

View File

@@ -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<typeof createPluginRuntime>) => {
expect(runtime.modelAuth).toBeDefined();
expectFunctionKeys(runtime.modelAuth as Record<string, unknown>, [
"getApiKeyForModel",
"getRuntimeAuthForModel",
"resolveApiKeyForProvider",
]);
},

View File

@@ -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,

View File

@@ -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<ResolvedProviderAuth, "apiKey"> & {
apiKey?: string;
baseUrl?: string;
request?: ProviderRequestTransportOverrides;
expiresAt?: number;
};

View File

@@ -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();
});
});

View File

@@ -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<Api>;
cfg?: OpenClawConfig;
workspaceDir?: string;
}): Promise<ResolvedProviderRuntimeAuth> {
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,
};
}

View File

@@ -125,6 +125,12 @@ export type PluginRuntimeCore = {
model: import("@mariozechner/pi-ai").Model<import("@mariozechner/pi-ai").Api>;
cfg?: import("../../config/config.js").OpenClawConfig;
}) => Promise<import("../../agents/model-auth.js").ResolvedProviderAuth>;
/** Resolve request-ready auth for a model, including provider runtime exchanges. */
getRuntimeAuthForModel: (params: {
model: import("@mariozechner/pi-ai").Model<import("@mariozechner/pi-ai").Api>;
cfg?: import("../../config/config.js").OpenClawConfig;
workspaceDir?: string;
}) => Promise<import("./model-auth-types.js").ResolvedProviderRuntimeAuth>;
/** Resolve auth for a provider by name. Only provider and optional cfg are used. */
resolveApiKeyForProvider: (params: {
provider: string;

View File

@@ -395,6 +395,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
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"],
},