mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix: expose runtime-ready provider auth to plugins (#62753)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
src/plugin-sdk/provider-auth-runtime.test.ts
Normal file
8
src/plugin-sdk/provider-auth-runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
src/plugins/runtime/model-auth-types.ts
Normal file
16
src/plugins/runtime/model-auth-types.ts
Normal 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;
|
||||
};
|
||||
118
src/plugins/runtime/runtime-model-auth.runtime.test.ts
Normal file
118
src/plugins/runtime/runtime-model-auth.runtime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user