259 lines
7.9 KiB
TypeScript

import {
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthResult,
} from "openclaw/plugin-sdk";
import { loginIronclawOAuth, type IronclawOAuthConfig } from "./oauth.js";
const PLUGIN_ID = "ironclaw-auth";
const PROVIDER_ID = "ironclaw";
const PROVIDER_LABEL = "Ironclaw";
const OAUTH_PLACEHOLDER = "ironclaw-oauth";
const DEFAULT_AUTH_URL = "https://auth.ironclaw.ai/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://auth.ironclaw.ai/oauth/token";
const DEFAULT_REDIRECT_URI = "http://127.0.0.1:47089/oauth/callback";
const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"];
const DEFAULT_BASE_URL = "https://api.ironclaw.ai/v1";
const DEFAULT_MODEL_ID = "chat";
const DEFAULT_CONTEXT_WINDOW = 128000;
const DEFAULT_MAX_TOKENS = 8192;
const CLIENT_ID_KEYS = ["IRONCLAW_OAUTH_CLIENT_ID", "OPENCLAW_IRONCLAW_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
"IRONCLAW_OAUTH_CLIENT_SECRET",
"OPENCLAW_IRONCLAW_OAUTH_CLIENT_SECRET",
];
const AUTH_URL_KEYS = ["IRONCLAW_OAUTH_AUTH_URL", "OPENCLAW_IRONCLAW_OAUTH_AUTH_URL"];
const TOKEN_URL_KEYS = ["IRONCLAW_OAUTH_TOKEN_URL", "OPENCLAW_IRONCLAW_OAUTH_TOKEN_URL"];
const REDIRECT_URI_KEYS = ["IRONCLAW_OAUTH_REDIRECT_URI", "OPENCLAW_IRONCLAW_OAUTH_REDIRECT_URI"];
const SCOPES_KEYS = ["IRONCLAW_OAUTH_SCOPES", "OPENCLAW_IRONCLAW_OAUTH_SCOPES"];
const USERINFO_URL_KEYS = ["IRONCLAW_OAUTH_USERINFO_URL", "OPENCLAW_IRONCLAW_OAUTH_USERINFO_URL"];
const BASE_URL_KEYS = [
"IRONCLAW_PROVIDER_BASE_URL",
"IRONCLAW_API_BASE_URL",
"OPENCLAW_IRONCLAW_PROVIDER_BASE_URL",
];
const MODEL_IDS_KEYS = [
"IRONCLAW_PROVIDER_MODEL_IDS",
"IRONCLAW_MODEL_IDS",
"OPENCLAW_IRONCLAW_MODEL_IDS",
];
const DEFAULT_MODEL_KEYS = [
"IRONCLAW_PROVIDER_DEFAULT_MODEL",
"IRONCLAW_DEFAULT_MODEL",
"OPENCLAW_IRONCLAW_DEFAULT_MODEL",
];
const ENV_VARS = [
...CLIENT_ID_KEYS,
...CLIENT_SECRET_KEYS,
...AUTH_URL_KEYS,
...TOKEN_URL_KEYS,
...REDIRECT_URI_KEYS,
...SCOPES_KEYS,
...USERINFO_URL_KEYS,
...BASE_URL_KEYS,
...MODEL_IDS_KEYS,
...DEFAULT_MODEL_KEYS,
];
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function stripProviderPrefix(model: string): string {
const normalized = model.trim();
if (normalized.startsWith(`${PROVIDER_ID}/`)) {
return normalized.slice(PROVIDER_ID.length + 1);
}
return normalized;
}
function parseList(value: string | undefined): string[] {
if (!value) {
return [];
}
const items = value
.split(/[\s,]+/)
.map((item) => stripProviderPrefix(item))
.map((item) => item.trim())
.filter(Boolean);
return Array.from(new Set(items));
}
function normalizeBaseUrl(value: string | undefined): string {
const raw = value?.trim() || DEFAULT_BASE_URL;
const withProtocol =
raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`;
const withoutTrailingSlash = withProtocol.replace(/\/+$/, "");
return withoutTrailingSlash.endsWith("/v1") ? withoutTrailingSlash : `${withoutTrailingSlash}/v1`;
}
function resolveDefaultModelId(modelIds: string[]): string {
const configured = resolveEnv(DEFAULT_MODEL_KEYS);
const fallback = modelIds[0] ?? DEFAULT_MODEL_ID;
if (!configured) {
return fallback;
}
const normalized = stripProviderPrefix(configured);
return normalized.length > 0 ? normalized : fallback;
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
reasoning: false,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
function resolveOAuthConfig(): IronclawOAuthConfig {
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
["Ironclaw OAuth client id is required.", `Set one of: ${CLIENT_ID_KEYS.join(", ")}`].join(
"\n",
),
);
}
const scopes = parseList(resolveEnv(SCOPES_KEYS));
return {
clientId,
clientSecret: resolveEnv(CLIENT_SECRET_KEYS),
authUrl: resolveEnv(AUTH_URL_KEYS) || DEFAULT_AUTH_URL,
tokenUrl: resolveEnv(TOKEN_URL_KEYS) || DEFAULT_TOKEN_URL,
redirectUri: resolveEnv(REDIRECT_URI_KEYS) || DEFAULT_REDIRECT_URI,
scopes: scopes.length > 0 ? scopes : DEFAULT_SCOPES,
userInfoUrl: resolveEnv(USERINFO_URL_KEYS),
};
}
function buildAuthResult(params: {
access: string;
refresh: string;
expires: number;
email?: string;
}): ProviderAuthResult {
const providerBaseUrl = normalizeBaseUrl(resolveEnv(BASE_URL_KEYS));
const modelIds = parseList(resolveEnv(MODEL_IDS_KEYS));
const normalizedModelIds = modelIds.length > 0 ? modelIds : [DEFAULT_MODEL_ID];
const defaultModelId = resolveDefaultModelId(normalizedModelIds);
const finalModelIds = normalizedModelIds.includes(defaultModelId)
? normalizedModelIds
: [defaultModelId, ...normalizedModelIds];
const defaultModel = `${PROVIDER_ID}/${defaultModelId}`;
const agentModels = Object.fromEntries(
finalModelIds.map((modelId, index) => [
`${PROVIDER_ID}/${modelId}`,
index === 0 ? { alias: "ironclaw" } : {},
]),
);
return {
profiles: [
{
profileId: `${PROVIDER_ID}:default`,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: params.access,
refresh: params.refresh,
expires: params.expires,
},
},
],
configPatch: {
models: {
providers: {
[PROVIDER_ID]: {
baseUrl: providerBaseUrl,
apiKey: OAUTH_PLACEHOLDER,
api: "openai-completions",
models: finalModelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: agentModels,
},
},
},
defaultModel,
notes: [
`Configured ${PROVIDER_ID} provider at ${providerBaseUrl}.`,
`Default model set to ${defaultModel}.`,
...(params.email ? [`Authenticated as ${params.email}.`] : []),
],
};
}
const ironclawAuthPlugin = {
id: PLUGIN_ID,
name: "Ironclaw OAuth",
description: "OAuth flow for Ironclaw-hosted models",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["ironclaw-ai"],
envVars: ENV_VARS,
auth: [
{
id: "oauth",
label: "Ironclaw OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx: ProviderAuthContext) => {
const progress = ctx.prompter.progress("Starting Ironclaw OAuth...");
try {
const oauthConfig = resolveOAuthConfig();
const result = await loginIronclawOAuth(
{
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
log: (message) => ctx.runtime.log(message),
note: ctx.prompter.note,
prompt: async (message) => String(await ctx.prompter.text({ message })),
progress,
},
oauthConfig,
);
progress.stop("Ironclaw OAuth complete");
return buildAuthResult(result);
} catch (error) {
progress.stop("Ironclaw OAuth failed");
await ctx.prompter.note(
[
"Set IRONCLAW_OAUTH_CLIENT_ID (and optionally auth/token URLs) before retrying.",
"You can also configure model ids with IRONCLAW_PROVIDER_MODEL_IDS.",
].join("\n"),
"Ironclaw OAuth",
);
throw error;
}
},
},
],
});
},
};
export default ironclawAuthPlugin;