@2en/clawly-plugins 1.16.0 → 1.16.2
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/gateway/index.ts +2 -0
- package/gateway/memory.ts +35 -1
- package/gateway/offline-push.test.ts +149 -0
- package/gateway/offline-push.ts +66 -0
- package/gateway/presence.test.ts +32 -0
- package/gateway/presence.ts +7 -2
- package/index.ts +3 -0
- package/model-gateway-setup.ts +101 -0
- package/openclaw.plugin.json +3 -1
- package/package.json +2 -1
package/gateway/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {registerClawhub2gateway} from './clawhub2gateway'
|
|
|
5
5
|
import {registerInject} from './inject'
|
|
6
6
|
import {registerMemoryBrowser} from './memory'
|
|
7
7
|
import {registerNotification} from './notification'
|
|
8
|
+
import {registerOfflinePush} from './offline-push'
|
|
8
9
|
import {registerPlugins} from './plugins'
|
|
9
10
|
import {registerPresence} from './presence'
|
|
10
11
|
|
|
@@ -17,4 +18,5 @@ export function registerGateway(api: PluginApi) {
|
|
|
17
18
|
registerClawhub2gateway(api)
|
|
18
19
|
registerPlugins(api)
|
|
19
20
|
registerChannelsConfigure(api)
|
|
21
|
+
registerOfflinePush(api)
|
|
20
22
|
}
|
package/gateway/memory.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - clawly.workspace.get — read a .md file from workspace root
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import fsSync from 'node:fs'
|
|
11
12
|
import fs from 'node:fs/promises'
|
|
12
13
|
import os from 'node:os'
|
|
13
14
|
import path from 'node:path'
|
|
@@ -39,6 +40,37 @@ function coercePluginConfig(api: PluginApi): Record<string, unknown> {
|
|
|
39
40
|
return isRecord(api.pluginConfig) ? api.pluginConfig : {}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function resolveStateDir(api: PluginApi): string {
|
|
44
|
+
return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read the default agent's workspace path from openclaw.json (cached). */
|
|
48
|
+
let _cachedAgentWorkspace: string | null | undefined
|
|
49
|
+
function readAgentWorkspace(api: PluginApi): string | null {
|
|
50
|
+
if (_cachedAgentWorkspace !== undefined) return _cachedAgentWorkspace
|
|
51
|
+
try {
|
|
52
|
+
const stateDir = resolveStateDir(api)
|
|
53
|
+
if (!stateDir) {
|
|
54
|
+
_cachedAgentWorkspace = null
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
const raw = fsSync.readFileSync(path.join(stateDir, 'openclaw.json'), 'utf-8')
|
|
58
|
+
const config = JSON.parse(raw)
|
|
59
|
+
const agents = config?.agents?.list
|
|
60
|
+
if (!Array.isArray(agents)) {
|
|
61
|
+
_cachedAgentWorkspace = null
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
const defaultAgent = agents.find((a: any) => a.default) ?? agents[0]
|
|
65
|
+
const ws = typeof defaultAgent?.workspace === 'string' ? defaultAgent.workspace : null
|
|
66
|
+
_cachedAgentWorkspace = ws
|
|
67
|
+
return ws
|
|
68
|
+
} catch {
|
|
69
|
+
_cachedAgentWorkspace = null
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
42
74
|
/** Resolve the workspace root directory (without /memory suffix). */
|
|
43
75
|
function resolveWorkspaceRoot(api: PluginApi, profile?: string): string {
|
|
44
76
|
const cfg = coercePluginConfig(api)
|
|
@@ -46,7 +78,9 @@ function resolveWorkspaceRoot(api: PluginApi, profile?: string): string {
|
|
|
46
78
|
if (configPath) return path.dirname(configPath) // strip /memory if configured
|
|
47
79
|
|
|
48
80
|
const baseDir =
|
|
49
|
-
process.env.OPENCLAW_WORKSPACE ??
|
|
81
|
+
process.env.OPENCLAW_WORKSPACE ??
|
|
82
|
+
readAgentWorkspace(api) ??
|
|
83
|
+
path.join(os.homedir(), '.openclaw', 'workspace')
|
|
50
84
|
if (profile && profile !== 'main') {
|
|
51
85
|
const parentDir = path.dirname(baseDir)
|
|
52
86
|
const baseName = path.basename(baseDir)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import type {PluginApi} from '../index'
|
|
3
|
+
import {PUSH_COOLDOWN_S, _resetCooldowns, registerOfflinePush} from './offline-push'
|
|
4
|
+
|
|
5
|
+
// ── Mocks ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
let mockOnline = false
|
|
8
|
+
let mockPushSent = true
|
|
9
|
+
|
|
10
|
+
mock.module('./presence', () => ({
|
|
11
|
+
isClientOnline: async () => mockOnline,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
mock.module('./notification', () => ({
|
|
15
|
+
sendPushNotification: async () => mockPushSent,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function createMockApi(): {
|
|
21
|
+
api: PluginApi
|
|
22
|
+
logs: {level: string; msg: string}[]
|
|
23
|
+
handlers: Map<string, (event: Record<string, unknown>) => Promise<void>>
|
|
24
|
+
} {
|
|
25
|
+
const logs: {level: string; msg: string}[] = []
|
|
26
|
+
const handlers = new Map<string, (event: Record<string, unknown>) => Promise<void>>()
|
|
27
|
+
const api = {
|
|
28
|
+
id: 'test',
|
|
29
|
+
name: 'test',
|
|
30
|
+
logger: {
|
|
31
|
+
info: (msg: string) => logs.push({level: 'info', msg}),
|
|
32
|
+
warn: (msg: string) => logs.push({level: 'warn', msg}),
|
|
33
|
+
error: (msg: string) => logs.push({level: 'error', msg}),
|
|
34
|
+
},
|
|
35
|
+
on: (hookName: string, handler: (...args: any[]) => any) => {
|
|
36
|
+
handlers.set(hookName, handler as any)
|
|
37
|
+
},
|
|
38
|
+
} as unknown as PluginApi
|
|
39
|
+
return {api, logs, handlers}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Tests ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
mockOnline = false
|
|
46
|
+
mockPushSent = true
|
|
47
|
+
_resetCooldowns()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('offline-push', () => {
|
|
51
|
+
test('sends push when client is offline', async () => {
|
|
52
|
+
const {api, logs, handlers} = createMockApi()
|
|
53
|
+
registerOfflinePush(api)
|
|
54
|
+
|
|
55
|
+
const handler = handlers.get('agent_end')!
|
|
56
|
+
await handler({sessionKey: 'sess-1'})
|
|
57
|
+
|
|
58
|
+
expect(logs).toContainEqual({
|
|
59
|
+
level: 'info',
|
|
60
|
+
msg: expect.stringContaining('notified (session=sess-1)'),
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('skips push when client is online', async () => {
|
|
65
|
+
mockOnline = true
|
|
66
|
+
const {api, logs, handlers} = createMockApi()
|
|
67
|
+
registerOfflinePush(api)
|
|
68
|
+
|
|
69
|
+
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
70
|
+
|
|
71
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
72
|
+
expect(logs.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('respects per-session cooldown', async () => {
|
|
76
|
+
const {api, logs, handlers} = createMockApi()
|
|
77
|
+
registerOfflinePush(api)
|
|
78
|
+
|
|
79
|
+
const handler = handlers.get('agent_end')!
|
|
80
|
+
|
|
81
|
+
// First call — should send
|
|
82
|
+
await handler({sessionKey: 'sess-1'})
|
|
83
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(1)
|
|
84
|
+
|
|
85
|
+
// Second call same session — should be cooldown-skipped
|
|
86
|
+
await handler({sessionKey: 'sess-1'})
|
|
87
|
+
expect(logs).toContainEqual({
|
|
88
|
+
level: 'info',
|
|
89
|
+
msg: expect.stringContaining('skipped (cooldown, session=sess-1)'),
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('different sessions have independent cooldowns', async () => {
|
|
94
|
+
const {api, logs, handlers} = createMockApi()
|
|
95
|
+
registerOfflinePush(api)
|
|
96
|
+
|
|
97
|
+
const handler = handlers.get('agent_end')!
|
|
98
|
+
|
|
99
|
+
await handler({sessionKey: 'sess-1'})
|
|
100
|
+
await handler({sessionKey: 'sess-2'})
|
|
101
|
+
|
|
102
|
+
const notified = logs.filter((l) => l.msg.includes('notified'))
|
|
103
|
+
expect(notified).toHaveLength(2)
|
|
104
|
+
expect(notified[0].msg).toContain('sess-1')
|
|
105
|
+
expect(notified[1].msg).toContain('sess-2')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('uses __global__ key when sessionKey is missing', async () => {
|
|
109
|
+
const {api, logs, handlers} = createMockApi()
|
|
110
|
+
registerOfflinePush(api)
|
|
111
|
+
|
|
112
|
+
const handler = handlers.get('agent_end')!
|
|
113
|
+
await handler({})
|
|
114
|
+
await handler({})
|
|
115
|
+
|
|
116
|
+
expect(logs).toContainEqual({
|
|
117
|
+
level: 'info',
|
|
118
|
+
msg: expect.stringContaining('notified (session=unknown)'),
|
|
119
|
+
})
|
|
120
|
+
expect(logs).toContainEqual({
|
|
121
|
+
level: 'info',
|
|
122
|
+
msg: expect.stringContaining('skipped (cooldown, session=__global__)'),
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('does not update cooldown when push is not sent', async () => {
|
|
127
|
+
mockPushSent = false
|
|
128
|
+
const {api, logs, handlers} = createMockApi()
|
|
129
|
+
registerOfflinePush(api)
|
|
130
|
+
|
|
131
|
+
const handler = handlers.get('agent_end')!
|
|
132
|
+
|
|
133
|
+
// Push returns false (e.g. no token) — cooldown should NOT be set
|
|
134
|
+
await handler({sessionKey: 'sess-1'})
|
|
135
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
136
|
+
|
|
137
|
+
// Second call should NOT be cooldown-skipped since first wasn't sent
|
|
138
|
+
mockPushSent = true
|
|
139
|
+
await handler({sessionKey: 'sess-1'})
|
|
140
|
+
expect(logs).toContainEqual({
|
|
141
|
+
level: 'info',
|
|
142
|
+
msg: expect.stringContaining('notified (session=sess-1)'),
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('exports PUSH_COOLDOWN_S as 30', () => {
|
|
147
|
+
expect(PUSH_COOLDOWN_S).toBe(30)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline push notification on agent_end — sends a push notification
|
|
3
|
+
* when an agent run completes and the mobile client is disconnected.
|
|
4
|
+
*
|
|
5
|
+
* Hook: agent_end → check presence → send Expo push if offline
|
|
6
|
+
*
|
|
7
|
+
* Avoids double-push with clawly.agent.send (which pushes at dispatch
|
|
8
|
+
* time for cron/external triggers) via a 30-second cooldown window.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {PluginApi} from '../index'
|
|
12
|
+
import {sendPushNotification} from './notification'
|
|
13
|
+
import {isClientOnline} from './presence'
|
|
14
|
+
|
|
15
|
+
/** Minimum seconds between consecutive push notifications per session. */
|
|
16
|
+
export const PUSH_COOLDOWN_S = 30
|
|
17
|
+
|
|
18
|
+
/** Per-session cooldown tracker. Key = sessionKey (or "__global__" for unknown). */
|
|
19
|
+
const lastPushBySession = new Map<string, number>()
|
|
20
|
+
|
|
21
|
+
export function registerOfflinePush(api: PluginApi) {
|
|
22
|
+
api.on('agent_end', async (event: Record<string, unknown>) => {
|
|
23
|
+
try {
|
|
24
|
+
// Skip if client is still connected — they got the response in real-time.
|
|
25
|
+
const online = await isClientOnline()
|
|
26
|
+
if (online) return
|
|
27
|
+
|
|
28
|
+
const sessionKey = typeof event.sessionKey === 'string' ? event.sessionKey : undefined
|
|
29
|
+
const cooldownKey = sessionKey ?? '__global__'
|
|
30
|
+
|
|
31
|
+
// Cooldown: skip if a push was sent recently for this session
|
|
32
|
+
// (e.g. by clawly.agent.send).
|
|
33
|
+
const now = Date.now() / 1000
|
|
34
|
+
const lastPush = lastPushBySession.get(cooldownKey) ?? 0
|
|
35
|
+
if (now - lastPush < PUSH_COOLDOWN_S) {
|
|
36
|
+
api.logger.info(`offline-push: skipped (cooldown, session=${cooldownKey})`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sent = await sendPushNotification(
|
|
41
|
+
{
|
|
42
|
+
body: 'Your response is ready',
|
|
43
|
+
data: {
|
|
44
|
+
type: 'agent_end',
|
|
45
|
+
...(sessionKey ? {sessionKey} : {}),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
api,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if (sent) {
|
|
52
|
+
lastPushBySession.set(cooldownKey, Date.now() / 1000)
|
|
53
|
+
api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
api.logger.error(`offline-push: ${err instanceof Error ? err.message : String(err)}`)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
api.logger.info('offline-push: registered agent_end hook')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @internal — exposed for testing */
|
|
64
|
+
export function _resetCooldowns() {
|
|
65
|
+
lastPushBySession.clear()
|
|
66
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {isOnlineEntry} from './presence'
|
|
3
|
+
|
|
4
|
+
describe('isOnlineEntry', () => {
|
|
5
|
+
test('returns true for reason "foreground"', () => {
|
|
6
|
+
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('returns true for reason "connect" (backward compat)', () => {
|
|
10
|
+
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('returns false for reason "background"', () => {
|
|
14
|
+
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'background'})).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('returns false for reason "disconnect"', () => {
|
|
18
|
+
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'disconnect'})).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('returns false for undefined entry (host not found)', () => {
|
|
22
|
+
expect(isOnlineEntry(undefined)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('returns false for entry with no reason', () => {
|
|
26
|
+
expect(isOnlineEntry({host: 'openclaw-ios'})).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('returns false for empty entry', () => {
|
|
30
|
+
expect(isOnlineEntry({})).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
package/gateway/presence.ts
CHANGED
|
@@ -19,6 +19,12 @@ interface PresenceEntry {
|
|
|
19
19
|
reason?: string
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Returns true if the presence entry indicates the client is actively connected. */
|
|
23
|
+
export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
|
|
24
|
+
if (!entry) return false
|
|
25
|
+
return entry.reason === 'foreground' || entry.reason === 'connect'
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
/**
|
|
23
29
|
* Shells out to `openclaw gateway call system-presence` and checks
|
|
24
30
|
* whether the given host has a non-"disconnect" entry.
|
|
@@ -29,8 +35,7 @@ export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
|
|
|
29
35
|
const jsonStr = stripCliLogs(result.stdout)
|
|
30
36
|
const entries: PresenceEntry[] = JSON.parse(jsonStr)
|
|
31
37
|
const entry = entries.find((e) => e.host === host)
|
|
32
|
-
|
|
33
|
-
return entry.reason === 'connect'
|
|
38
|
+
return isOnlineEntry(entry)
|
|
34
39
|
} catch {
|
|
35
40
|
return false
|
|
36
41
|
}
|
package/index.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Hooks:
|
|
25
25
|
* - tool_result_persist — copies TTS audio to persistent outbound directory
|
|
26
26
|
* - before_tool_call — enforces delivery fields on cron.create
|
|
27
|
+
* - agent_end — sends push notification when client is offline
|
|
27
28
|
* - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
|
|
28
29
|
*/
|
|
29
30
|
|
|
@@ -35,6 +36,7 @@ import {registerCronHook} from './cron-hook'
|
|
|
35
36
|
import {registerEmail} from './email'
|
|
36
37
|
import {registerGateway} from './gateway'
|
|
37
38
|
import {getGatewayConfig} from './gateway-fetch'
|
|
39
|
+
import {setupModelGateway} from './model-gateway-setup'
|
|
38
40
|
import {registerOutboundHook, registerOutboundHttpRoute, registerOutboundMethods} from './outbound'
|
|
39
41
|
import {registerTools} from './tools'
|
|
40
42
|
|
|
@@ -117,6 +119,7 @@ export default {
|
|
|
117
119
|
registerCronHook(api)
|
|
118
120
|
registerGateway(api)
|
|
119
121
|
registerAutoPair(api)
|
|
122
|
+
setupModelGateway(api)
|
|
120
123
|
|
|
121
124
|
// Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
|
|
122
125
|
const gw = getGatewayConfig(api)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On plugin init, patches openclaw.json to add the `clawly-model-gateway`
|
|
3
|
+
* model provider entry. Credentials come from pluginConfig; the model list
|
|
4
|
+
* is derived from `agents.defaults.model` / `agents.defaults.imageModel`
|
|
5
|
+
* already present in the config.
|
|
6
|
+
*
|
|
7
|
+
* This runs synchronously during plugin registration (before gateway_start).
|
|
8
|
+
* OpenClaw loads the config file once at startup, so writing before the
|
|
9
|
+
* gateway fully starts ensures the provider is active on first boot.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import type {PluginApi} from './index'
|
|
16
|
+
|
|
17
|
+
const PROVIDER_NAME = 'clawly-model-gateway'
|
|
18
|
+
|
|
19
|
+
function resolveStateDir(api: PluginApi): string {
|
|
20
|
+
return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readOpenclawConfig(configPath: string): Record<string, unknown> {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
26
|
+
} catch {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeOpenclawConfig(configPath: string, config: Record<string, unknown>) {
|
|
32
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setupModelGateway(api: PluginApi): void {
|
|
36
|
+
const cfg = api.pluginConfig as Record<string, unknown> | undefined
|
|
37
|
+
const baseUrl =
|
|
38
|
+
typeof cfg?.modelGatewayBaseUrl === 'string' ? cfg.modelGatewayBaseUrl.replace(/\/$/, '') : ''
|
|
39
|
+
const token = typeof cfg?.modelGatewayToken === 'string' ? cfg.modelGatewayToken : ''
|
|
40
|
+
|
|
41
|
+
if (!baseUrl || !token) {
|
|
42
|
+
api.logger.info('Model gateway not configured (missing baseUrl or token), skipping.')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stateDir = resolveStateDir(api)
|
|
47
|
+
if (!stateDir) {
|
|
48
|
+
api.logger.warn('Cannot resolve state dir — model gateway setup skipped.')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
53
|
+
const config = readOpenclawConfig(configPath)
|
|
54
|
+
|
|
55
|
+
// Already configured — skip
|
|
56
|
+
if (config.models?.providers?.[PROVIDER_NAME]) {
|
|
57
|
+
api.logger.info('Model gateway provider already configured.')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Derive model IDs from agents.defaults
|
|
62
|
+
const defaultModelFull: string = (config.agents as any)?.defaults?.model?.primary ?? ''
|
|
63
|
+
const imageModelFull: string = (config.agents as any)?.defaults?.imageModel?.primary ?? ''
|
|
64
|
+
|
|
65
|
+
const prefix = `${PROVIDER_NAME}/`
|
|
66
|
+
const defaultModel = defaultModelFull.startsWith(prefix)
|
|
67
|
+
? defaultModelFull.slice(prefix.length)
|
|
68
|
+
: defaultModelFull
|
|
69
|
+
const imageModel = imageModelFull.startsWith(prefix)
|
|
70
|
+
? imageModelFull.slice(prefix.length)
|
|
71
|
+
: imageModelFull
|
|
72
|
+
|
|
73
|
+
if (!defaultModel) {
|
|
74
|
+
api.logger.warn('No default model found in agents.defaults — model gateway setup skipped.')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const models =
|
|
79
|
+
defaultModel === imageModel || !imageModel
|
|
80
|
+
? [{id: defaultModel, name: defaultModel, input: ['text', 'image']}]
|
|
81
|
+
: [
|
|
82
|
+
{id: defaultModel, name: defaultModel, input: ['text']},
|
|
83
|
+
{id: imageModel, name: imageModel, input: ['text', 'image']},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
if (!config.models) config.models = {}
|
|
87
|
+
if (!(config.models as any).providers) (config.models as any).providers = {}
|
|
88
|
+
;(config.models as any).providers[PROVIDER_NAME] = {
|
|
89
|
+
baseUrl,
|
|
90
|
+
apiKey: token,
|
|
91
|
+
api: 'openai-completions',
|
|
92
|
+
models,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
writeOpenclawConfig(configPath, config)
|
|
97
|
+
api.logger.info(`Model gateway provider configured: ${baseUrl} with ${models.length} model(s).`)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
api.logger.error(`Failed to setup model gateway: ${(err as Error).message}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
"defaultTimeoutMs": { "type": "number", "minimum": 1000 },
|
|
48
48
|
"configPath": { "type": "string" },
|
|
49
49
|
"skillGatewayBaseUrl": { "type": "string" },
|
|
50
|
-
"skillGatewayToken": { "type": "string" }
|
|
50
|
+
"skillGatewayToken": { "type": "string" },
|
|
51
|
+
"modelGatewayBaseUrl": { "type": "string" },
|
|
52
|
+
"modelGatewayToken": { "type": "string" }
|
|
51
53
|
},
|
|
52
54
|
"required": []
|
|
53
55
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.2",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"email.ts",
|
|
25
25
|
"gateway-fetch.ts",
|
|
26
26
|
"outbound.ts",
|
|
27
|
+
"model-gateway-setup.ts",
|
|
27
28
|
"openclaw.plugin.json"
|
|
28
29
|
],
|
|
29
30
|
"publishConfig": {
|