@openclaw/radius-provider 2026.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # OpenClaw Radius Provider
2
+
3
+ Connect OpenClaw to Earendil's Radius gateway with browser sign-in or an
4
+ organization API key. The plugin discovers account-visible models and supports
5
+ native Pi message streaming, reasoning, images on supported models, and tool calls.
6
+
7
+ ```bash
8
+ openclaw plugins install @openclaw/radius-provider
9
+ openclaw models auth login --provider radius --method oauth --set-default
10
+ openclaw models list --provider radius --refresh
11
+ ```
12
+
13
+ For API-key setups, use `--method api-key` or `RADIUS_API_KEY`.
14
+ Requests use the selected organization's credits and policies.
15
+
16
+ See [Radius setup and configuration](https://docs.openclaw.ai/providers/radius).
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
2
+ <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0Z M12 12 19 5" />
3
+ <path fill="currentColor" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.5 12a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0 -3 0Z" />
4
+ </svg>
Binary file
@@ -0,0 +1,111 @@
1
+ import { fetchLiveProviderModelRows } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
2
+ import { asFiniteNumberInRange, asOptionalRecord, asPositiveSafeInteger, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
3
+ //#region extensions/radius/catalog.ts
4
+ const RADIUS_BASE_URL = "https://radius.pi.dev/v1";
5
+ const THINKING_LEVELS = [
6
+ "off",
7
+ "minimal",
8
+ "low",
9
+ "medium",
10
+ "high",
11
+ "xhigh",
12
+ "max"
13
+ ];
14
+ function readRates(value) {
15
+ const row = asOptionalRecord(value);
16
+ const input = asFiniteNumberInRange(row?.input, { min: 0 });
17
+ const output = asFiniteNumberInRange(row?.output, { min: 0 });
18
+ const cacheRead = asFiniteNumberInRange(row?.cacheRead, { min: 0 });
19
+ const cacheWrite = asFiniteNumberInRange(row?.cacheWrite, { min: 0 });
20
+ return input !== void 0 && output !== void 0 && cacheRead !== void 0 && cacheWrite !== void 0 ? {
21
+ input,
22
+ output,
23
+ cacheRead,
24
+ cacheWrite
25
+ } : void 0;
26
+ }
27
+ function readCost(value) {
28
+ const rates = readRates(value);
29
+ if (!rates) return;
30
+ const tiers = asOptionalRecord(value)?.tiers;
31
+ if (tiers === void 0) return rates;
32
+ if (!Array.isArray(tiers)) return;
33
+ const thresholds = [{
34
+ start: 0,
35
+ rates
36
+ }];
37
+ for (const tier of tiers) {
38
+ const tierRates = readRates(tier);
39
+ const threshold = asFiniteNumberInRange(asOptionalRecord(tier)?.inputTokensAbove, { min: 0 });
40
+ if (!tierRates || threshold === void 0 || !Number.isSafeInteger(threshold + 1)) return;
41
+ thresholds.push({
42
+ start: threshold + 1,
43
+ rates: tierRates
44
+ });
45
+ }
46
+ thresholds.sort((left, right) => left.start - right.start);
47
+ if (thresholds.some((tier, index) => index > 0 && tier.start === thresholds[index - 1]?.start)) return;
48
+ return {
49
+ ...rates,
50
+ ...tiers.length > 0 ? { tieredPricing: thresholds.map((tier, index) => {
51
+ const next = thresholds[index + 1];
52
+ const range = next ? [tier.start, next.start] : [tier.start];
53
+ return Object.assign({}, tier.rates, { range });
54
+ }) } : {}
55
+ };
56
+ }
57
+ function readModel(value) {
58
+ const row = asOptionalRecord(value);
59
+ const id = normalizeOptionalString(row?.id);
60
+ const name = normalizeOptionalString(row?.name);
61
+ const cost = readCost(row?.cost);
62
+ const contextWindow = asPositiveSafeInteger(row?.contextWindow);
63
+ const maxTokens = asPositiveSafeInteger(row?.maxTokens);
64
+ if (!row || !id || !name || !cost || !contextWindow || !maxTokens || row.enabled === false || typeof row.reasoning !== "boolean" || !Array.isArray(row.input) || row.input.length === 0 || row.input.some((input) => input !== "text" && input !== "image")) return;
65
+ const input = row.input.filter((item) => item === "text" || item === "image");
66
+ let thinkingLevelMap;
67
+ if (row.thinkingLevelMap !== void 0) {
68
+ const levels = asOptionalRecord(row.thinkingLevelMap);
69
+ if (!levels) return;
70
+ thinkingLevelMap = {};
71
+ for (const level of THINKING_LEVELS) {
72
+ const mapped = levels[level];
73
+ if (mapped !== void 0) {
74
+ if (mapped !== null && typeof mapped !== "string") return;
75
+ thinkingLevelMap[level] = mapped;
76
+ }
77
+ }
78
+ }
79
+ return {
80
+ id,
81
+ name,
82
+ reasoning: row.reasoning,
83
+ input,
84
+ cost,
85
+ contextWindow,
86
+ maxTokens,
87
+ ...thinkingLevelMap ? { thinkingLevelMap } : {}
88
+ };
89
+ }
90
+ async function fetchRadiusCatalog(apiKey, signal) {
91
+ const documents = await fetchLiveProviderModelRows({
92
+ providerId: "radius",
93
+ endpoint: `${RADIUS_BASE_URL}/config`,
94
+ discoveryApiKey: apiKey,
95
+ signal,
96
+ requireHttps: true,
97
+ readRows: (body) => [body]
98
+ });
99
+ const config = asOptionalRecord(documents[0]);
100
+ const baseUrl = normalizeOptionalString(config?.baseUrl);
101
+ if (!baseUrl || !Array.isArray(config?.models)) throw new Error("Invalid Radius catalog: expected baseUrl and models");
102
+ const endpoint = new URL(baseUrl);
103
+ if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error("Invalid Radius catalog base URL");
104
+ return {
105
+ baseUrl: baseUrl.replace(/\/+$/u, ""),
106
+ api: "pi-messages",
107
+ models: config.models.map(readModel).filter((model) => model !== void 0)
108
+ };
109
+ }
110
+ //#endregion
111
+ export { RADIUS_BASE_URL, fetchRadiusCatalog };
package/dist/index.js ADDED
@@ -0,0 +1,170 @@
1
+ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
2
+ import { buildOauthProviderAuthResult, resolveOAuthApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
3
+ import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
4
+ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
5
+ //#region extensions/radius/openclaw.plugin.json
6
+ var openclaw_plugin_default = {
7
+ id: "radius",
8
+ name: "Radius",
9
+ description: "Radius model gateway provider.",
10
+ categories: ["models"],
11
+ activation: { "onStartup": false },
12
+ enabledByDefault: true,
13
+ providers: ["radius"],
14
+ modelCatalog: { "discovery": { "radius": "refreshable" } },
15
+ setup: { "providers": [{
16
+ "id": "radius",
17
+ "envVars": ["RADIUS_API_KEY"]
18
+ }] },
19
+ providerAuthChoices: [{
20
+ "provider": "radius",
21
+ "method": "oauth",
22
+ "choiceId": "radius",
23
+ "appGuidedAuth": "oauth",
24
+ "credentialOnly": true,
25
+ "channelLogin": {},
26
+ "choiceLabel": "Radius (browser sign-in)",
27
+ "groupId": "radius",
28
+ "groupLabel": "Radius",
29
+ "groupHint": "Browser sign-in or API key"
30
+ }, {
31
+ "provider": "radius",
32
+ "method": "api-key",
33
+ "choiceId": "radius-api-key",
34
+ "appGuidedSecret": true,
35
+ "choiceLabel": "Radius API key",
36
+ "groupId": "radius",
37
+ "groupLabel": "Radius",
38
+ "groupHint": "Browser sign-in or API key",
39
+ "optionKey": "radiusApiKey",
40
+ "cliFlag": "--radius-api-key",
41
+ "cliOption": "--radius-api-key <key>",
42
+ "cliDescription": "Radius organization API key"
43
+ }],
44
+ configSchema: {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "properties": {}
48
+ }
49
+ };
50
+ //#endregion
51
+ //#region extensions/radius/index.ts
52
+ const loadCatalog = createLazyRuntimeModule(() => import("./catalog.js"));
53
+ const loadOAuth = createLazyRuntimeModule(() => import("./oauth.js"));
54
+ const loadStream = createLazyRuntimeModule(() => import("./stream.js"));
55
+ function defaultModel(catalog) {
56
+ const model = catalog.models.find((entry) => entry.id === "balanced") ?? catalog.models[0];
57
+ if (!model) throw new Error("Radius returned no available models. Check your organization's model access.");
58
+ return `radius/${model.id}`;
59
+ }
60
+ var radius_default = defineSingleProviderPluginEntry({
61
+ id: "radius",
62
+ name: "Radius Provider",
63
+ description: "Radius model gateway with organization-scoped authentication and native streaming",
64
+ manifest: openclaw_plugin_default,
65
+ provider: {
66
+ label: "Radius",
67
+ docsPath: "/providers/radius",
68
+ manifestAuth: {
69
+ noteTitle: "Radius",
70
+ noteMessage: "Create an organization API key at https://radius.earendil.com. Requests use that organization's credits and policies.",
71
+ resolveDefaultModel: async ({ apiKey, signal }) => defaultModel(await (await loadCatalog()).fetchRadiusCatalog(apiKey, signal))
72
+ },
73
+ extraAuth: [{
74
+ id: "oauth",
75
+ label: "Radius browser sign-in",
76
+ hint: "Pair your browser and choose a Radius organization",
77
+ kind: "device_code",
78
+ wizard: {
79
+ choiceId: "radius",
80
+ choiceLabel: "Radius (browser sign-in)",
81
+ groupId: "radius",
82
+ groupLabel: "Radius",
83
+ groupHint: "Browser sign-in or API key"
84
+ },
85
+ run: async (ctx) => {
86
+ const credentials = await (await loadOAuth()).loginRadiusOAuth(ctx);
87
+ const notes = ["Radius access is scoped to the organization selected in your browser. Tokens refresh automatically."];
88
+ if (ctx.credentialOnly) {
89
+ ctx.assertCurrent?.();
90
+ return {
91
+ profiles: [{
92
+ profileId: "radius:default",
93
+ credential: {
94
+ type: "oauth",
95
+ provider: "radius",
96
+ ...credentials
97
+ }
98
+ }],
99
+ notes
100
+ };
101
+ }
102
+ const catalog = await (await loadCatalog()).fetchRadiusCatalog(credentials.access, ctx.signal);
103
+ ctx.assertCurrent?.();
104
+ return buildOauthProviderAuthResult({
105
+ providerId: "radius",
106
+ defaultModel: defaultModel(catalog),
107
+ access: credentials.access,
108
+ refresh: credentials.refresh,
109
+ expires: credentials.expires,
110
+ notes
111
+ });
112
+ }
113
+ }],
114
+ catalog: {
115
+ order: "profile",
116
+ run: async (ctx) => {
117
+ const { apiKey, discoveryApiKey, profileId } = ctx.resolveProviderAuth("radius", { oauthMarker: resolveOAuthApiKeyMarker("radius") });
118
+ if (!discoveryApiKey) return null;
119
+ return await runLiveProviderCatalog({
120
+ providerId: "radius",
121
+ profileId,
122
+ run: async () => ({ provider: {
123
+ ...await (await loadCatalog()).fetchRadiusCatalog(discoveryApiKey),
124
+ apiKey
125
+ } })
126
+ });
127
+ }
128
+ },
129
+ prepareDynamicModel: async (ctx) => {
130
+ const { resolveApiKeyForProvider } = await import("openclaw/plugin-sdk/provider-auth-runtime");
131
+ const { apiKey } = await resolveApiKeyForProvider({
132
+ provider: "radius",
133
+ cfg: ctx.config,
134
+ agentDir: ctx.agentDir,
135
+ workspaceDir: ctx.workspaceDir,
136
+ ...ctx.authProfileId ? {
137
+ profileId: ctx.authProfileId,
138
+ lockedProfile: true
139
+ } : {}
140
+ });
141
+ if (!apiKey) return;
142
+ const catalog = await (await loadCatalog()).fetchRadiusCatalog(apiKey);
143
+ const model = catalog.models.find((entry) => entry.id === ctx.modelId);
144
+ return model ? {
145
+ id: model.id,
146
+ name: model.name,
147
+ provider: "radius",
148
+ api: "pi-messages",
149
+ baseUrl: catalog.baseUrl,
150
+ reasoning: model.reasoning,
151
+ input: model.input,
152
+ cost: model.cost,
153
+ contextWindow: model.contextWindow,
154
+ maxTokens: model.maxTokens,
155
+ thinkingLevelMap: model.thinkingLevelMap
156
+ } : void 0;
157
+ },
158
+ createStreamFn: () => async (...args) => (await loadStream()).createRadiusStreamFn()(...args),
159
+ buildReplayPolicy: () => ({
160
+ sanitizeToolCallIds: false,
161
+ preserveSignatures: true,
162
+ appendOnlyRuntimeContext: true,
163
+ dropThinkingBlocks: false,
164
+ dropReasoningFromHistory: false
165
+ }),
166
+ refreshOAuth: async (credential) => (await loadOAuth()).refreshRadiusOAuthCredential(credential)
167
+ }
168
+ });
169
+ //#endregion
170
+ export { radius_default as default };
package/dist/oauth.js ADDED
@@ -0,0 +1,162 @@
1
+ import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime";
2
+ import { MAX_TIMER_TIMEOUT_MS, positiveSecondsToSafeMilliseconds, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
3
+ import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http";
4
+ import { throwIfOAuthLoginAborted } from "openclaw/plugin-sdk/provider-oauth-runtime";
5
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
6
+ import { sleep } from "openclaw/plugin-sdk/text-utility-runtime";
7
+ //#region extensions/radius/oauth.ts
8
+ const OAUTH_BASE_URL = "https://radius.pi.dev/v1/oauth";
9
+ const CLIENT_ID = "pi-gateway";
10
+ const REQUEST_TIMEOUT_MS = 3e4;
11
+ var RadiusOAuthError = class extends Error {
12
+ constructor(code, status) {
13
+ super(`Radius OAuth request failed (HTTP ${status}). Retry sign-in.`);
14
+ this.code = typeof code === "string" && [
15
+ "authorization_pending",
16
+ "slow_down",
17
+ "access_denied",
18
+ "expired_token"
19
+ ].includes(code) ? code : void 0;
20
+ }
21
+ };
22
+ async function postOAuthForm(endpoint, fields, signal, beforeRequest) {
23
+ const { response, release } = await fetchWithSsrFGuard({
24
+ url: `${OAUTH_BASE_URL}/${endpoint}`,
25
+ init: {
26
+ method: "POST",
27
+ headers: {
28
+ Accept: "application/json",
29
+ "Content-Type": "application/x-www-form-urlencoded"
30
+ },
31
+ body: new URLSearchParams({
32
+ client_id: CLIENT_ID,
33
+ ...fields
34
+ })
35
+ },
36
+ signal,
37
+ beforeRequest,
38
+ timeoutMs: REQUEST_TIMEOUT_MS,
39
+ requireHttps: true,
40
+ policy: { hostnameAllowlist: ["radius.pi.dev"] },
41
+ auditContext: `radius.oauth.${endpoint}`
42
+ });
43
+ try {
44
+ const payload = await readProviderJsonObjectResponse(response, "Radius OAuth", {
45
+ maxBytes: 65536,
46
+ requestHeaders: {}
47
+ });
48
+ if (!response.ok || payload.error !== void 0) throw new RadiusOAuthError(payload.error, response.status);
49
+ return payload;
50
+ } finally {
51
+ await release();
52
+ }
53
+ }
54
+ function parseToken(payload, previousRefresh) {
55
+ const lifetimeMs = positiveSecondsToSafeMilliseconds(payload.expires_in);
56
+ const expires = lifetimeMs === void 0 ? void 0 : resolveExpiresAtMsFromDurationMs(lifetimeMs, { bufferMs: Math.min(6e4, Math.floor(lifetimeMs / 2)) });
57
+ const refresh = payload.refresh_token ?? previousRefresh;
58
+ if (typeof payload.access_token !== "string" || !payload.access_token.trim() || typeof refresh !== "string" || !refresh.trim() || expires === void 0 || payload.scope !== void 0 && typeof payload.scope !== "string") throw new Error("Radius OAuth returned invalid credentials. Retry sign-in.");
59
+ return {
60
+ access: payload.access_token,
61
+ refresh,
62
+ expires
63
+ };
64
+ }
65
+ function parseDevice(payload) {
66
+ const lifetimeMs = positiveSecondsToSafeMilliseconds(payload.expires_in);
67
+ const expiresAt = lifetimeMs === void 0 ? void 0 : resolveExpiresAtMsFromDurationMs(lifetimeMs);
68
+ const intervalSeconds = payload.interval === void 0 ? 5 : asFiniteNumberInRange(payload.interval, {
69
+ min: Number.MIN_VALUE,
70
+ max: MAX_TIMER_TIMEOUT_MS / 1e3
71
+ });
72
+ const intervalMs = intervalSeconds === void 0 ? void 0 : Math.ceil(intervalSeconds * 1e3);
73
+ if (typeof payload.device_code !== "string" || !payload.device_code.trim() || typeof payload.user_code !== "string" || !payload.user_code.trim() || typeof payload.verification_uri !== "string" || expiresAt === void 0 || intervalMs === void 0 || intervalMs > MAX_TIMER_TIMEOUT_MS) throw new Error("Radius OAuth returned an invalid device authorization response.");
74
+ let url;
75
+ try {
76
+ url = new URL(payload.verification_uri);
77
+ } catch {
78
+ throw new Error("Radius OAuth returned an invalid verification URL.");
79
+ }
80
+ if (url.origin !== "https://radius.earendil.com" || url.username || url.password) throw new Error("Radius OAuth returned an invalid verification URL.");
81
+ return {
82
+ deviceCode: payload.device_code,
83
+ userCode: payload.user_code,
84
+ verificationUri: url.href,
85
+ expiresAt,
86
+ intervalMs
87
+ };
88
+ }
89
+ async function loginRadiusOAuth(ctx) {
90
+ const assertCurrent = () => {
91
+ throwIfOAuthLoginAborted(ctx.signal);
92
+ ctx.assertCurrent?.();
93
+ };
94
+ assertCurrent();
95
+ const payload = await postOAuthForm("device", { scope: "gateway offline_access" }, ctx.signal, assertCurrent);
96
+ assertCurrent();
97
+ const device = parseDevice(payload);
98
+ if (ctx.prompter.deviceCode) {
99
+ await ctx.openUrl(device.verificationUri);
100
+ await ctx.prompter.deviceCode({
101
+ title: "Radius sign-in",
102
+ code: device.userCode,
103
+ expiresInMinutes: Math.ceil((device.expiresAt - Date.now()) / 6e4),
104
+ message: "Enter this one-time code to sign in to Radius."
105
+ });
106
+ } else await ctx.prompter.note(`Open ${device.verificationUri} and enter code ${device.userCode} to sign in to Radius.`, "Radius sign-in");
107
+ assertCurrent();
108
+ if (!ctx.isRemote && !ctx.prompter.deviceCode) {
109
+ try {
110
+ await ctx.openUrl(device.verificationUri);
111
+ } catch {}
112
+ assertCurrent();
113
+ }
114
+ const progress = ctx.prompter.progress("Waiting for Radius approval…");
115
+ let completed = false;
116
+ try {
117
+ let intervalMs = device.intervalMs;
118
+ while (Date.now() < device.expiresAt) {
119
+ await sleep(Math.min(intervalMs, device.expiresAt - Date.now()), ctx.signal);
120
+ assertCurrent();
121
+ if (Date.now() >= device.expiresAt) break;
122
+ try {
123
+ const token = await postOAuthForm("token", {
124
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
125
+ device_code: device.deviceCode
126
+ }, ctx.signal, assertCurrent);
127
+ assertCurrent();
128
+ const credentials = parseToken(token);
129
+ completed = true;
130
+ return credentials;
131
+ } catch (error) {
132
+ assertCurrent();
133
+ if (!(error instanceof RadiusOAuthError)) throw error;
134
+ switch (error.code) {
135
+ case "authorization_pending": break;
136
+ case "slow_down":
137
+ intervalMs = Math.min(intervalMs + 5e3, MAX_TIMER_TIMEOUT_MS);
138
+ break;
139
+ case "access_denied": throw new Error("Radius sign-in was denied. Retry sign-in when ready.", { cause: error });
140
+ case "expired_token": throw new Error("Radius device code expired. Start sign-in again.", { cause: error });
141
+ default: throw error;
142
+ }
143
+ }
144
+ }
145
+ throw new Error("Radius device code expired. Start sign-in again.");
146
+ } finally {
147
+ progress.stop(completed ? "Radius sign-in complete" : "Radius sign-in stopped");
148
+ }
149
+ }
150
+ async function refreshRadiusOAuthCredential(credential) {
151
+ if (!credential.refresh.trim()) throw new Error("Radius refresh token is missing. Sign in again.");
152
+ const payload = await postOAuthForm("token", {
153
+ grant_type: "refresh_token",
154
+ refresh_token: credential.refresh
155
+ });
156
+ return {
157
+ ...credential,
158
+ ...parseToken(payload, credential.refresh)
159
+ };
160
+ }
161
+ //#endregion
162
+ export { loginRadiusOAuth, refreshRadiusOAuthCredential };
package/dist/stream.js ADDED
@@ -0,0 +1,291 @@
1
+ import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
2
+ import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
3
+ import { createAssistantMessageEventStream, createToolArgumentPreviewSchedule, parseStreamingJson } from "openclaw/plugin-sdk/llm";
4
+ import { buildGuardedModelFetch, createEmptyTransportUsage, failTransportStream, notifyProviderHttpResponse, parseTerminalToolCallArguments } from "openclaw/plugin-sdk/provider-transport-runtime";
5
+ //#region extensions/radius/stream.ts
6
+ const MAX_SSE_EVENT_BYTES = 16777216;
7
+ function record(value) {
8
+ const parsed = asOptionalRecord(value);
9
+ if (!parsed) throw new Error("Invalid Radius stream event object");
10
+ return parsed;
11
+ }
12
+ function string(value) {
13
+ if (typeof value !== "string") throw new Error("Invalid Radius stream string");
14
+ return value;
15
+ }
16
+ function number(value) {
17
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Invalid Radius usage value");
18
+ return value;
19
+ }
20
+ function usage(value) {
21
+ const raw = record(value);
22
+ const cost = record(raw.cost);
23
+ return {
24
+ ...raw,
25
+ input: number(raw.input),
26
+ output: number(raw.output),
27
+ cacheRead: number(raw.cacheRead),
28
+ cacheWrite: number(raw.cacheWrite),
29
+ totalTokens: number(raw.totalTokens),
30
+ cost: {
31
+ ...cost,
32
+ input: number(cost.input),
33
+ output: number(cost.output),
34
+ cacheRead: number(cost.cacheRead),
35
+ cacheWrite: number(cost.cacheWrite),
36
+ total: number(cost.total)
37
+ }
38
+ };
39
+ }
40
+ function createEventConverter(partial) {
41
+ const toolJson = /* @__PURE__ */ new Map();
42
+ return (raw) => {
43
+ const event = record(raw);
44
+ const type = string(event.type);
45
+ if (type === "done" || type === "error") {
46
+ partial.usage = usage(event.usage);
47
+ if (event.responseId !== void 0) partial.responseId = string(event.responseId);
48
+ const reason = event.reason;
49
+ if (type === "done" && (reason === "stop" || reason === "length" || reason === "toolUse")) {
50
+ if (toolJson.size > 0) throw new Error("Radius stream ended with an unfinished tool call");
51
+ partial.stopReason = reason;
52
+ return {
53
+ type,
54
+ reason,
55
+ message: partial
56
+ };
57
+ }
58
+ if (type === "error" && (reason === "error" || reason === "aborted")) {
59
+ partial.stopReason = reason;
60
+ partial.errorMessage = event.errorMessage === void 0 ? "Radius request failed" : string(event.errorMessage);
61
+ return {
62
+ type,
63
+ reason,
64
+ error: partial
65
+ };
66
+ }
67
+ throw new Error("Invalid Radius terminal reason");
68
+ }
69
+ if (type === "start") return {
70
+ type,
71
+ partial
72
+ };
73
+ const contentIndex = event.contentIndex;
74
+ if (typeof contentIndex !== "number" || !Number.isSafeInteger(contentIndex) || contentIndex < 0) throw new Error("Invalid Radius content index");
75
+ if (type === "text_start" || type === "thinking_start" || type === "toolcall_start") {
76
+ if (contentIndex !== partial.content.length) throw new Error("Out-of-order Radius content block");
77
+ if (type === "text_start") partial.content.push({
78
+ type: "text",
79
+ text: ""
80
+ });
81
+ else if (type === "thinking_start") partial.content.push({
82
+ type: "thinking",
83
+ thinking: ""
84
+ });
85
+ else {
86
+ partial.content.push({
87
+ type: "toolCall",
88
+ id: string(event.id),
89
+ name: string(event.toolName),
90
+ arguments: {}
91
+ });
92
+ toolJson.set(contentIndex, {
93
+ json: "",
94
+ shouldPreview: createToolArgumentPreviewSchedule()
95
+ });
96
+ }
97
+ return {
98
+ type,
99
+ contentIndex,
100
+ partial
101
+ };
102
+ }
103
+ const block = partial.content[contentIndex];
104
+ switch (type) {
105
+ case "text_delta": {
106
+ if (block?.type !== "text") break;
107
+ const delta = string(event.delta);
108
+ block.text += delta;
109
+ return {
110
+ type,
111
+ contentIndex,
112
+ delta,
113
+ partial
114
+ };
115
+ }
116
+ case "text_end":
117
+ if (block?.type !== "text") break;
118
+ block.text = string(event.content);
119
+ if (event.contentSignature !== void 0) block.textSignature = string(event.contentSignature);
120
+ return {
121
+ type,
122
+ contentIndex,
123
+ content: block.text,
124
+ partial
125
+ };
126
+ case "thinking_delta": {
127
+ if (block?.type !== "thinking") break;
128
+ const delta = string(event.delta);
129
+ block.thinking += delta;
130
+ return {
131
+ type,
132
+ contentIndex,
133
+ delta,
134
+ partial
135
+ };
136
+ }
137
+ case "thinking_end":
138
+ if (block?.type !== "thinking") break;
139
+ block.thinking = string(event.content);
140
+ if (event.contentSignature !== void 0) block.thinkingSignature = string(event.contentSignature);
141
+ if (event.redacted !== void 0 && typeof event.redacted !== "boolean") throw new Error("Invalid Radius redacted thinking flag");
142
+ block.redacted = event.redacted;
143
+ return {
144
+ type,
145
+ contentIndex,
146
+ content: block.thinking,
147
+ partial
148
+ };
149
+ case "toolcall_delta": {
150
+ const pending = toolJson.get(contentIndex);
151
+ if (block?.type !== "toolCall" || !pending) break;
152
+ const delta = string(event.delta);
153
+ pending.json += delta;
154
+ if (pending.shouldPreview(pending.json.length)) block.arguments = parseStreamingJson(pending.json);
155
+ return {
156
+ type,
157
+ contentIndex,
158
+ delta,
159
+ partial
160
+ };
161
+ }
162
+ case "toolcall_end": {
163
+ if (block?.type !== "toolCall" || !toolJson.has(contentIndex)) break;
164
+ const call = record(event.toolCall);
165
+ if (call.type !== "toolCall" || call.id !== block.id || call.name !== block.name) throw new Error("Radius terminal tool call does not match its start");
166
+ block.arguments = parseTerminalToolCallArguments(call.arguments);
167
+ if (call.thoughtSignature !== void 0) block.thoughtSignature = string(call.thoughtSignature);
168
+ toolJson.delete(contentIndex);
169
+ return {
170
+ type,
171
+ contentIndex,
172
+ toolCall: block,
173
+ partial
174
+ };
175
+ }
176
+ }
177
+ throw new Error("Invalid Radius stream event or content block sequence");
178
+ };
179
+ }
180
+ async function* readEvents(body, signal) {
181
+ const reader = body.getReader();
182
+ const decoder = new TextDecoder();
183
+ let buffer = "";
184
+ const cancel = () => {
185
+ reader.cancel(signal?.reason).catch(() => void 0);
186
+ };
187
+ signal?.addEventListener("abort", cancel, { once: true });
188
+ try {
189
+ while (true) {
190
+ signal?.throwIfAborted();
191
+ const { done, value } = await reader.read();
192
+ signal?.throwIfAborted();
193
+ buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
194
+ for (;;) {
195
+ const boundary = /\r\n\r\n|\n\n|\r\r/u.exec(buffer);
196
+ if (!boundary && !done) break;
197
+ const frame = boundary ? buffer.slice(0, boundary.index) : buffer;
198
+ if (Buffer.byteLength(frame, "utf8") > MAX_SSE_EVENT_BYTES) throw new Error("Radius SSE event exceeds 16 MiB");
199
+ buffer = boundary ? buffer.slice(boundary.index + boundary[0].length) : "";
200
+ const data = frame.split(/\r\n|\n|\r/u).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
201
+ if (data && data !== "[DONE]") yield JSON.parse(data);
202
+ if (!boundary) break;
203
+ }
204
+ if (Buffer.byteLength(buffer, "utf8") > MAX_SSE_EVENT_BYTES) throw new Error("Radius SSE event exceeds 16 MiB");
205
+ if (done) return;
206
+ }
207
+ } finally {
208
+ signal?.removeEventListener("abort", cancel);
209
+ await reader.cancel().catch(() => void 0);
210
+ reader.releaseLock();
211
+ }
212
+ }
213
+ /** Radius speaks Pi's native message protocol, not an OpenAI-compatible API. */
214
+ function createRadiusStreamFn() {
215
+ return (model, context, options) => {
216
+ const stream = createAssistantMessageEventStream();
217
+ const partial = {
218
+ role: "assistant",
219
+ content: [],
220
+ api: model.api,
221
+ provider: model.provider,
222
+ model: model.id,
223
+ usage: createEmptyTransportUsage(),
224
+ stopReason: "stop",
225
+ timestamp: Date.now()
226
+ };
227
+ const convert = createEventConverter(partial);
228
+ (async () => {
229
+ let response;
230
+ try {
231
+ if (!options?.apiKey) throw new Error("No Radius access token configured. Run openclaw models auth login --provider radius.");
232
+ options.signal?.throwIfAborted();
233
+ let payload = {
234
+ model: model.id,
235
+ context,
236
+ options: {
237
+ temperature: options.temperature,
238
+ maxTokens: options.maxTokens,
239
+ reasoning: options.reasoning === "off" ? void 0 : options.reasoning,
240
+ cacheRetention: options.cacheRetention,
241
+ sessionId: options.sessionId,
242
+ toolChoice: options.toolChoice
243
+ }
244
+ };
245
+ const replacement = await options.onPayload?.(payload, model);
246
+ if (replacement !== void 0) payload = replacement;
247
+ options.signal?.throwIfAborted();
248
+ response = await buildGuardedModelFetch(model, options.timeoutMs, { sanitizeSse: false })(`${model.baseUrl.replace(/\/+$/u, "")}/messages`, {
249
+ method: "POST",
250
+ headers: {
251
+ authorization: `Bearer ${options.apiKey}`,
252
+ accept: "text/event-stream",
253
+ "content-type": "application/json",
254
+ ...model.headers,
255
+ ...options.headers
256
+ },
257
+ body: JSON.stringify(payload),
258
+ signal: options.signal
259
+ });
260
+ await notifyProviderHttpResponse({
261
+ options,
262
+ response,
263
+ model
264
+ });
265
+ if (!response.ok) throw await createProviderHttpError(response, "Radius request failed");
266
+ if (!response.body) throw new Error("Radius response has no body");
267
+ for await (const raw of readEvents(response.body, options.signal)) {
268
+ const event = convert(raw);
269
+ stream.push(event);
270
+ if (event.type === "done" || event.type === "error") {
271
+ stream.end();
272
+ return;
273
+ }
274
+ }
275
+ throw new Error("Radius stream ended without a terminal event");
276
+ } catch (error) {
277
+ failTransportStream({
278
+ stream,
279
+ output: partial,
280
+ signal: options?.signal,
281
+ error
282
+ });
283
+ } finally {
284
+ await response?.body?.cancel().catch(() => void 0);
285
+ }
286
+ })();
287
+ return stream;
288
+ };
289
+ }
290
+ //#endregion
291
+ export { createRadiusStreamFn };
@@ -0,0 +1,40 @@
1
+ {
2
+ "id": "radius",
3
+ "name": "Radius",
4
+ "description": "Radius model gateway provider.",
5
+ "categories": ["models"],
6
+ "activation": { "onStartup": false },
7
+ "enabledByDefault": true,
8
+ "providers": ["radius"],
9
+ "modelCatalog": { "discovery": { "radius": "refreshable" } },
10
+ "setup": { "providers": [{ "id": "radius", "envVars": ["RADIUS_API_KEY"] }] },
11
+ "providerAuthChoices": [
12
+ {
13
+ "provider": "radius",
14
+ "method": "oauth",
15
+ "choiceId": "radius",
16
+ "appGuidedAuth": "oauth",
17
+ "credentialOnly": true,
18
+ "channelLogin": {},
19
+ "choiceLabel": "Radius (browser sign-in)",
20
+ "groupId": "radius",
21
+ "groupLabel": "Radius",
22
+ "groupHint": "Browser sign-in or API key"
23
+ },
24
+ {
25
+ "provider": "radius",
26
+ "method": "api-key",
27
+ "choiceId": "radius-api-key",
28
+ "appGuidedSecret": true,
29
+ "choiceLabel": "Radius API key",
30
+ "groupId": "radius",
31
+ "groupLabel": "Radius",
32
+ "groupHint": "Browser sign-in or API key",
33
+ "optionKey": "radiusApiKey",
34
+ "cliFlag": "--radius-api-key",
35
+ "cliOption": "--radius-api-key <key>",
36
+ "cliDescription": "Radius organization API key"
37
+ }
38
+ ],
39
+ "configSchema": { "type": "object", "additionalProperties": false, "properties": {} }
40
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@openclaw/radius-provider",
3
+ "version": "2026.9.5",
4
+ "description": "OpenClaw Radius model gateway provider plugin.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/openclaw/openclaw"
8
+ },
9
+ "type": "module",
10
+ "openclaw": {
11
+ "extensions": [
12
+ "./index.ts"
13
+ ],
14
+ "install": {
15
+ "clawhubSpec": "clawhub:@openclaw/radius-provider",
16
+ "npmSpec": "@openclaw/radius-provider",
17
+ "defaultChoice": "npm",
18
+ "minHostVersion": ">=2026.9.4"
19
+ },
20
+ "compat": {
21
+ "pluginApi": ">=2026.9.5"
22
+ },
23
+ "build": {
24
+ "openclawVersion": "2026.9.5",
25
+ "bundledDist": false
26
+ },
27
+ "release": {
28
+ "publishToClawHub": true,
29
+ "publishToNpm": true
30
+ },
31
+ "runtimeExtensions": [
32
+ "./dist/index.js"
33
+ ]
34
+ },
35
+ "files": [
36
+ "dist/**",
37
+ "openclaw.plugin.json",
38
+ "README.md",
39
+ "assets/icon.png",
40
+ "assets/activity.svg"
41
+ ],
42
+ "peerDependencies": {
43
+ "openclaw": ">=2026.9.5"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "openclaw": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "bundledDependencies": []
51
+ }