refactor(plugins): move auth and model policy to providers

This commit is contained in:
Peter Steinberger
2026-03-15 20:58:59 -07:00
parent ca2f046668
commit a33caab280
30 changed files with 1080 additions and 653 deletions

View File

@@ -7,8 +7,16 @@ import {
} from "../../src/test-utils/provider-usage-fetch.js";
import googlePlugin from "./index.js";
function findProvider(providers: ProviderPlugin[], id: string): ProviderPlugin {
const provider = providers.find((candidate) => candidate.id === id);
if (!provider) {
throw new Error(`provider ${id} missing`);
}
return provider;
}
function registerGooglePlugin(): {
provider: ProviderPlugin;
providers: ProviderPlugin[];
webSearchProvider: {
id: string;
envVars: string[];
@@ -18,13 +26,12 @@ function registerGooglePlugin(): {
} {
const captured = createCapturedPluginRegistration();
googlePlugin.register(captured.api);
const provider = captured.providers[0];
if (!provider) {
if (captured.providers.length === 0) {
throw new Error("provider registration missing");
}
const webSearchProvider = captured.webSearchProviders[0] ?? null;
return {
provider,
providers: captured.providers,
webSearchProviderRegistered: webSearchProvider !== null,
webSearchProvider:
webSearchProvider === null
@@ -38,10 +45,13 @@ function registerGooglePlugin(): {
}
describe("google plugin", () => {
it("registers both Gemini CLI auth and Gemini web search", () => {
it("registers Google direct, Gemini CLI auth, and Gemini web search", () => {
const result = registerGooglePlugin();
expect(result.provider.id).toBe("google-gemini-cli");
expect(result.providers.map((provider) => provider.id)).toEqual([
"google",
"google-gemini-cli",
]);
expect(result.webSearchProviderRegistered).toBe(true);
expect(result.webSearchProvider).toMatchObject({
id: "gemini",
@@ -50,8 +60,43 @@ describe("google plugin", () => {
});
});
it("owns gemini 3.1 forward-compat resolution", () => {
const { provider } = registerGooglePlugin();
it("owns google direct gemini 3.1 forward-compat resolution", () => {
const { providers } = registerGooglePlugin();
const provider = findProvider(providers, "google");
const model = provider.resolveDynamicModel?.({
provider: "google",
modelId: "gemini-3.1-pro-preview",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gemini-3-pro-preview"
? {
id,
name: id,
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
}
: null,
} as never,
});
expect(model).toMatchObject({
id: "gemini-3.1-pro-preview",
provider: "google",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com",
reasoning: true,
});
});
it("owns gemini cli 3.1 forward-compat resolution", () => {
const { providers } = registerGooglePlugin();
const provider = findProvider(providers, "google-gemini-cli");
const model = provider.resolveDynamicModel?.({
provider: "google-gemini-cli",
modelId: "gemini-3.1-pro-preview",
@@ -82,7 +127,8 @@ describe("google plugin", () => {
});
it("owns usage-token parsing", async () => {
const { provider } = registerGooglePlugin();
const { providers } = registerGooglePlugin();
const provider = findProvider(providers, "google-gemini-cli");
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
@@ -101,7 +147,8 @@ describe("google plugin", () => {
});
it("owns usage snapshot fetching", async () => {
const { provider } = registerGooglePlugin();
const { providers } = registerGooglePlugin();
const provider = findProvider(providers, "google-gemini-cli");
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
return makeResponse(200, {

View File

@@ -1,22 +1,16 @@
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js";
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";
import type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderFetchUsageSnapshotContext,
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "../../src/plugins/types.js";
import { loginGeminiCliOAuth } from "./oauth.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
const PROVIDER_ID = "google-gemini-cli";
const PROVIDER_LABEL = "Gemini CLI OAuth";
const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview";
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const;
const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const;
const ENV_VARS = [
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
@@ -24,30 +18,6 @@ const ENV_VARS = [
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
function cloneFirstTemplateModel(params: {
modelId: string;
templateIds: readonly string[];
ctx: ProviderResolveDynamicModelContext;
}): ProviderRuntimeModel | undefined {
const trimmedModelId = params.modelId.trim();
for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) {
const template = params.ctx.modelRegistry.find(
PROVIDER_ID,
templateId,
) as ProviderRuntimeModel | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
reasoning: true,
} as ProviderRuntimeModel);
}
return undefined;
}
function parseGoogleUsageToken(apiKey: string): string {
try {
const parsed = JSON.parse(apiKey) as { token?: unknown };
@@ -64,28 +34,6 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
function resolveGeminiCliForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
const trimmed = ctx.modelId.trim();
const lower = trimmed.toLowerCase();
let templateIds: readonly string[];
if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) {
templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS;
} else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) {
templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS;
} else {
return undefined;
}
return cloneFirstTemplateModel({
modelId: trimmed,
templateIds,
ctx,
});
}
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
@@ -133,7 +81,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
},
},
],
resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx),
resolveDynamicModel: (ctx) =>
resolveGoogle31ForwardCompatModel({ providerId: PROVIDER_ID, ctx }),
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
resolveUsageAuth: async (ctx) => {
const auth = await ctx.resolveOAuthToken();
if (!auth) {

View File

@@ -6,6 +6,7 @@ import {
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
const googlePlugin = {
id: "google",
@@ -13,6 +14,16 @@ const googlePlugin = {
description: "Bundled Google plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: "google",
label: "Google AI Studio",
docsPath: "/providers/models",
envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
auth: [],
resolveDynamicModel: (ctx) =>
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
registerGoogleGeminiCliProvider(api);
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({

View File

@@ -1,6 +1,9 @@
{
"id": "google",
"providers": ["google-gemini-cli"],
"providers": ["google", "google-gemini-cli"],
"providerAuthEnvVars": {
"google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -0,0 +1,63 @@
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "../../src/plugins/types.js";
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const;
const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const;
function cloneFirstTemplateModel(params: {
providerId: string;
modelId: string;
templateIds: readonly string[];
ctx: ProviderResolveDynamicModelContext;
}): ProviderRuntimeModel | undefined {
const trimmedModelId = params.modelId.trim();
for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) {
const template = params.ctx.modelRegistry.find(
params.providerId,
templateId,
) as ProviderRuntimeModel | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
reasoning: true,
} as ProviderRuntimeModel);
}
return undefined;
}
export function resolveGoogle31ForwardCompatModel(params: {
providerId: string;
ctx: ProviderResolveDynamicModelContext;
}): ProviderRuntimeModel | undefined {
const trimmed = params.ctx.modelId.trim();
const lower = trimmed.toLowerCase();
let templateIds: readonly string[];
if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) {
templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS;
} else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) {
templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS;
} else {
return undefined;
}
return cloneFirstTemplateModel({
providerId: params.providerId,
modelId: trimmed,
templateIds,
ctx: params.ctx,
});
}
export function isModernGoogleModel(modelId: string): boolean {
return modelId.trim().toLowerCase().startsWith("gemini-3");
}