@lobu/worker 6.0.1 → 7.0.0
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/dist/embedded/exec-sandbox.d.ts +2 -2
- package/dist/embedded/exec-sandbox.js +7 -7
- package/dist/embedded/exec-sandbox.js.map +1 -1
- package/dist/embedded/just-bash-bootstrap.d.ts +2 -2
- package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
- package/dist/embedded/just-bash-bootstrap.js +30 -6
- package/dist/embedded/just-bash-bootstrap.js.map +1 -1
- package/dist/embedded/mcp-cli-commands.d.ts +5 -5
- package/dist/gateway/gateway-integration.js +4 -4
- package/dist/gateway/gateway-integration.js.map +1 -1
- package/dist/gateway/message-batcher.d.ts.map +1 -1
- package/dist/gateway/message-batcher.js +3 -5
- package/dist/gateway/message-batcher.js.map +1 -1
- package/dist/gateway/sse-client.d.ts +1 -0
- package/dist/gateway/sse-client.d.ts.map +1 -1
- package/dist/gateway/sse-client.js +13 -8
- package/dist/gateway/sse-client.js.map +1 -1
- package/dist/gateway/types.d.ts +1 -1
- package/dist/gateway/types.d.ts.map +1 -1
- package/dist/instructions/builder.d.ts +4 -0
- package/dist/instructions/builder.d.ts.map +1 -1
- package/dist/instructions/builder.js +8 -11
- package/dist/instructions/builder.js.map +1 -1
- package/dist/instructions/providers.d.ts +5 -5
- package/dist/instructions/providers.d.ts.map +1 -1
- package/dist/instructions/providers.js +3 -2
- package/dist/instructions/providers.js.map +1 -1
- package/dist/openclaw/custom-tools.d.ts +1 -1
- package/dist/openclaw/custom-tools.js +1 -1
- package/dist/openclaw/instructions.d.ts +9 -9
- package/dist/openclaw/instructions.d.ts.map +1 -1
- package/dist/openclaw/instructions.js +4 -4
- package/dist/openclaw/instructions.js.map +1 -1
- package/dist/openclaw/tools.js.map +1 -1
- package/dist/openclaw/worker.d.ts +0 -1
- package/dist/openclaw/worker.d.ts.map +1 -1
- package/dist/openclaw/worker.js +18 -75
- package/dist/openclaw/worker.js.map +1 -1
- package/dist/shared/tool-implementations.d.ts.map +1 -1
- package/dist/shared/tool-implementations.js +37 -13
- package/dist/shared/tool-implementations.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
- package/src/__tests__/custom-tools.test.ts +92 -0
- package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
- package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
- package/src/__tests__/embedded-tools.test.ts +744 -0
- package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
- package/src/__tests__/exec-sandbox.test.ts +550 -0
- package/src/__tests__/generated-media.test.ts +142 -0
- package/src/__tests__/instructions.test.ts +60 -0
- package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
- package/src/__tests__/mcp-cli-commands.test.ts +383 -0
- package/src/__tests__/mcp-tool-call.test.ts +423 -0
- package/src/__tests__/memory-flush-harden.test.ts +367 -0
- package/src/__tests__/memory-flush-runtime.test.ts +138 -0
- package/src/__tests__/memory-flush.test.ts +64 -0
- package/src/__tests__/message-batcher.test.ts +247 -0
- package/src/__tests__/model-resolver-harden.test.ts +197 -0
- package/src/__tests__/model-resolver.test.ts +156 -0
- package/src/__tests__/processor-harden.test.ts +269 -0
- package/src/__tests__/processor.test.ts +225 -0
- package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
- package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
- package/src/__tests__/sandbox-leak.test.ts +167 -0
- package/src/__tests__/setup.ts +102 -0
- package/src/__tests__/sse-client-harden.test.ts +588 -0
- package/src/__tests__/sse-client.test.ts +90 -0
- package/src/__tests__/tool-implementations.test.ts +196 -0
- package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
- package/src/__tests__/tool-policy.test.ts +269 -0
- package/src/__tests__/worker.test.ts +89 -0
- package/src/core/error-handler.ts +62 -0
- package/src/core/project-scanner.ts +65 -0
- package/src/core/types.ts +128 -0
- package/src/core/workspace.ts +89 -0
- package/src/embedded/exec-sandbox.ts +372 -0
- package/src/embedded/just-bash-bootstrap.ts +543 -0
- package/src/embedded/mcp-cli-commands.ts +402 -0
- package/src/gateway/gateway-integration.ts +298 -0
- package/src/gateway/message-batcher.ts +123 -0
- package/src/gateway/sse-client.ts +951 -0
- package/src/gateway/types.ts +68 -0
- package/src/index.ts +141 -0
- package/src/instructions/builder.ts +45 -0
- package/src/instructions/providers.ts +27 -0
- package/src/modules/lifecycle.ts +92 -0
- package/src/openclaw/custom-tools.ts +315 -0
- package/src/openclaw/instructions.ts +36 -0
- package/src/openclaw/model-resolver.ts +150 -0
- package/src/openclaw/plugin-loader.ts +427 -0
- package/src/openclaw/processor.ts +198 -0
- package/src/openclaw/sandbox-leak.ts +105 -0
- package/src/openclaw/session-context.ts +320 -0
- package/src/openclaw/tool-policy.ts +248 -0
- package/src/openclaw/tools.ts +277 -0
- package/src/openclaw/worker.ts +1847 -0
- package/src/server.ts +334 -0
- package/src/shared/audio-provider-suggestions.ts +132 -0
- package/src/shared/processor-utils.ts +33 -0
- package/src/shared/provider-auth-hints.ts +68 -0
- package/src/shared/tool-display-config.ts +75 -0
- package/src/shared/tool-implementations.ts +940 -0
- package/src/shared/worker-env-keys.ts +8 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardening tests for MessageBatcher.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - First message is processed immediately (no batch window)
|
|
6
|
+
* - Subsequent messages enter the batch window
|
|
7
|
+
* - Messages queued during processing are handled after the current batch
|
|
8
|
+
* - stop() cancels a pending batch timer
|
|
9
|
+
* - Messages are sorted by timestamp before delivery
|
|
10
|
+
* - onBatchReady receives combined messages in order
|
|
11
|
+
* - getPendingCount and isCurrentlyProcessing visibility
|
|
12
|
+
* - Error in onBatchReady is caught, isProcessing is reset to false
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { MessageBatcher } from "../gateway/message-batcher";
|
|
17
|
+
import type { QueuedMessage } from "../gateway/types";
|
|
18
|
+
|
|
19
|
+
function makeMsg(
|
|
20
|
+
messageId: string,
|
|
21
|
+
messageText = "hello",
|
|
22
|
+
timestamp = Date.now()
|
|
23
|
+
): QueuedMessage {
|
|
24
|
+
return {
|
|
25
|
+
timestamp,
|
|
26
|
+
payload: {
|
|
27
|
+
botId: "bot",
|
|
28
|
+
userId: "user-1",
|
|
29
|
+
agentId: "agent-1",
|
|
30
|
+
conversationId: "conv-1",
|
|
31
|
+
platform: "api",
|
|
32
|
+
channelId: "chan-1",
|
|
33
|
+
messageId,
|
|
34
|
+
messageText,
|
|
35
|
+
platformMetadata: {},
|
|
36
|
+
agentOptions: {},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// First message — immediate processing
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe("MessageBatcher — first message processed immediately", () => {
|
|
46
|
+
test("onBatchReady called synchronously (within the addMessage await) for first message", async () => {
|
|
47
|
+
const processed: string[][] = [];
|
|
48
|
+
const batcher = new MessageBatcher({
|
|
49
|
+
onBatchReady: async (msgs) => {
|
|
50
|
+
processed.push(msgs.map((m) => m.payload.messageId));
|
|
51
|
+
},
|
|
52
|
+
batchWindowMs: 5000, // long window — should not matter for first message
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await batcher.addMessage(makeMsg("msg-1", "hello", 1000));
|
|
56
|
+
expect(processed).toHaveLength(1);
|
|
57
|
+
expect(processed[0]).toEqual(["msg-1"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("getPendingCount is 0 after first message is processed", async () => {
|
|
61
|
+
const batcher = new MessageBatcher({
|
|
62
|
+
onBatchReady: async () => undefined,
|
|
63
|
+
});
|
|
64
|
+
await batcher.addMessage(makeMsg("msg-1"));
|
|
65
|
+
expect(batcher.getPendingCount()).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Messages during processing — queued for next batch
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe("MessageBatcher — message queued during processing", () => {
|
|
74
|
+
test("message added while onBatchReady is running is queued and processed after", async () => {
|
|
75
|
+
const batches: string[][] = [];
|
|
76
|
+
let resolveBatch: (() => void) | null = null;
|
|
77
|
+
|
|
78
|
+
const batcher = new MessageBatcher({
|
|
79
|
+
batchWindowMs: 50,
|
|
80
|
+
onBatchReady: async (msgs) => {
|
|
81
|
+
batches.push(msgs.map((m) => m.payload.messageId));
|
|
82
|
+
if (batches.length === 1) {
|
|
83
|
+
// Signal that first batch is about to complete; test adds a message now
|
|
84
|
+
await new Promise<void>((r) => {
|
|
85
|
+
resolveBatch = r;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Start first batch
|
|
92
|
+
const firstBatch = batcher.addMessage(makeMsg("msg-1", "first", 1));
|
|
93
|
+
// Wait until onBatchReady is blocking on the promise
|
|
94
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
95
|
+
|
|
96
|
+
// Add message while processing — should queue for next batch
|
|
97
|
+
batcher.addMessage(makeMsg("msg-2", "second", 2)).catch(() => undefined);
|
|
98
|
+
expect(batcher.getPendingCount()).toBe(1);
|
|
99
|
+
|
|
100
|
+
// Release the first onBatchReady
|
|
101
|
+
resolveBatch?.();
|
|
102
|
+
await firstBatch;
|
|
103
|
+
|
|
104
|
+
// Wait for the second batch timer to fire
|
|
105
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
106
|
+
|
|
107
|
+
expect(batches).toHaveLength(2);
|
|
108
|
+
expect(batches[0]).toEqual(["msg-1"]);
|
|
109
|
+
expect(batches[1]).toEqual(["msg-2"]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Batch window — messages collected and sorted
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("MessageBatcher — batch window collects messages by timestamp", () => {
|
|
118
|
+
test("two messages added within batch window arrive in timestamp order", async () => {
|
|
119
|
+
const batches: QueuedMessage[][] = [];
|
|
120
|
+
|
|
121
|
+
const batcher = new MessageBatcher({
|
|
122
|
+
batchWindowMs: 50,
|
|
123
|
+
onBatchReady: async (msgs) => {
|
|
124
|
+
batches.push(msgs);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// First message → triggers immediate processing (consumes initial batch)
|
|
129
|
+
await batcher.addMessage(makeMsg("msg-1", "first", 1));
|
|
130
|
+
|
|
131
|
+
// Now send two messages quickly within the batch window
|
|
132
|
+
await batcher.addMessage(makeMsg("msg-3", "third", 300));
|
|
133
|
+
await batcher.addMessage(makeMsg("msg-2", "second", 200));
|
|
134
|
+
|
|
135
|
+
// Wait for the batch window to fire
|
|
136
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
137
|
+
|
|
138
|
+
expect(batches).toHaveLength(2);
|
|
139
|
+
// Second batch should contain both messages sorted by timestamp
|
|
140
|
+
const secondBatch = batches[1];
|
|
141
|
+
expect(secondBatch).toHaveLength(2);
|
|
142
|
+
expect(secondBatch?.[0]?.payload.messageId).toBe("msg-2"); // ts=200 < ts=300
|
|
143
|
+
expect(secondBatch?.[1]?.payload.messageId).toBe("msg-3");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// stop() cancels pending timer
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe("MessageBatcher — stop()", () => {
|
|
152
|
+
test("stop() after first message prevents queued timer from firing", async () => {
|
|
153
|
+
const processed: string[][] = [];
|
|
154
|
+
const batcher = new MessageBatcher({
|
|
155
|
+
batchWindowMs: 50,
|
|
156
|
+
onBatchReady: async (msgs) => {
|
|
157
|
+
processed.push(msgs.map((m) => m.payload.messageId));
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// First message processed immediately
|
|
162
|
+
await batcher.addMessage(makeMsg("msg-1", "first", 1));
|
|
163
|
+
|
|
164
|
+
// Add a second message which starts the batch timer
|
|
165
|
+
await batcher.addMessage(makeMsg("msg-2", "second", 2));
|
|
166
|
+
// Stop before the timer fires
|
|
167
|
+
batcher.stop();
|
|
168
|
+
|
|
169
|
+
// Wait longer than the batch window
|
|
170
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
171
|
+
|
|
172
|
+
// Only the first batch should have been delivered
|
|
173
|
+
expect(processed).toHaveLength(1);
|
|
174
|
+
expect(processed[0]).toEqual(["msg-1"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("getPendingCount remains 1 after stop() if message was queued", async () => {
|
|
178
|
+
const batcher = new MessageBatcher({
|
|
179
|
+
batchWindowMs: 50,
|
|
180
|
+
onBatchReady: async () => undefined,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await batcher.addMessage(makeMsg("msg-1", "first", 1));
|
|
184
|
+
await batcher.addMessage(makeMsg("msg-2", "second", 2));
|
|
185
|
+
|
|
186
|
+
batcher.stop();
|
|
187
|
+
|
|
188
|
+
// The timer was cleared; the queued message is still in the queue
|
|
189
|
+
expect(batcher.getPendingCount()).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// isCurrentlyProcessing visibility
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
describe("MessageBatcher — isCurrentlyProcessing", () => {
|
|
198
|
+
test("isCurrentlyProcessing is true during onBatchReady and false after", async () => {
|
|
199
|
+
const states: boolean[] = [];
|
|
200
|
+
let markProcessingDone: (() => void) | null = null;
|
|
201
|
+
|
|
202
|
+
const batcher = new MessageBatcher({
|
|
203
|
+
onBatchReady: async () => {
|
|
204
|
+
states.push(batcher.isCurrentlyProcessing());
|
|
205
|
+
await new Promise<void>((r) => {
|
|
206
|
+
markProcessingDone = r;
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const p = batcher.addMessage(makeMsg("msg-1", "hello", 1));
|
|
212
|
+
// Give the event loop a tick so onBatchReady starts
|
|
213
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
214
|
+
|
|
215
|
+
expect(batcher.isCurrentlyProcessing()).toBe(true);
|
|
216
|
+
markProcessingDone?.();
|
|
217
|
+
await p;
|
|
218
|
+
expect(batcher.isCurrentlyProcessing()).toBe(false);
|
|
219
|
+
expect(states).toContain(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Error in onBatchReady resets isProcessing
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
describe("MessageBatcher — error resilience", () => {
|
|
228
|
+
test("error thrown in onBatchReady resets isProcessing to false", async () => {
|
|
229
|
+
const batcher = new MessageBatcher({
|
|
230
|
+
onBatchReady: async () => {
|
|
231
|
+
throw new Error("batch processing failed");
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// addMessage is fire-and-forget via setTimeout for subsequent batches,
|
|
236
|
+
// but first message is awaited synchronously — error is swallowed in the
|
|
237
|
+
// private processBatch() try/finally.
|
|
238
|
+
try {
|
|
239
|
+
await batcher.addMessage(makeMsg("msg-1", "hello", 1));
|
|
240
|
+
} catch {
|
|
241
|
+
// may or may not propagate depending on implementation path
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// After any error path, isProcessing must be false
|
|
245
|
+
expect(batcher.isCurrentlyProcessing()).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardening tests for model-resolver edge cases.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - resolveModelRef with multi-segment model IDs (provider/org/model)
|
|
6
|
+
* - "auto" resolving to provider default
|
|
7
|
+
* - Unknown provider in "provider/model" format still parsed (no registry check)
|
|
8
|
+
* - resolveModelRef with null/undefined input (type boundary)
|
|
9
|
+
* - registerDynamicProvider idempotency and alias precedence
|
|
10
|
+
* - DEFAULT_PROVIDER_MODELS covers the known provider set
|
|
11
|
+
* - No real credentials in resolver output
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_PROVIDER_BASE_URL_ENV,
|
|
17
|
+
DEFAULT_PROVIDER_MODELS,
|
|
18
|
+
PROVIDER_REGISTRY_ALIASES,
|
|
19
|
+
registerDynamicProvider,
|
|
20
|
+
resolveModelRef,
|
|
21
|
+
} from "../openclaw/model-resolver";
|
|
22
|
+
|
|
23
|
+
// Unique-enough prefix to avoid clashing with parallel test workers
|
|
24
|
+
const PREFIX = `test-${process.pid}-${Date.now()}`;
|
|
25
|
+
|
|
26
|
+
describe("resolveModelRef — edge cases", () => {
|
|
27
|
+
let origDefaultModel: string | undefined;
|
|
28
|
+
let origDefaultProvider: string | undefined;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
origDefaultModel = process.env.AGENT_DEFAULT_MODEL;
|
|
32
|
+
origDefaultProvider = process.env.AGENT_DEFAULT_PROVIDER;
|
|
33
|
+
delete process.env.AGENT_DEFAULT_MODEL;
|
|
34
|
+
delete process.env.AGENT_DEFAULT_PROVIDER;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
if (origDefaultModel !== undefined)
|
|
39
|
+
process.env.AGENT_DEFAULT_MODEL = origDefaultModel;
|
|
40
|
+
else delete process.env.AGENT_DEFAULT_MODEL;
|
|
41
|
+
|
|
42
|
+
if (origDefaultProvider !== undefined)
|
|
43
|
+
process.env.AGENT_DEFAULT_PROVIDER = origDefaultProvider;
|
|
44
|
+
else delete process.env.AGENT_DEFAULT_PROVIDER;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("multi-segment model: nvidia/org/model-id", () => {
|
|
48
|
+
const result = resolveModelRef("nvidia/moonshotai/kimi-k2.5");
|
|
49
|
+
expect(result.provider).toBe("nvidia");
|
|
50
|
+
expect(result.modelId).toBe("moonshotai/kimi-k2.5");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("three-part openai-compat path", () => {
|
|
54
|
+
const result = resolveModelRef("openai-codex/gpt-5.1-codex-max");
|
|
55
|
+
expect(result.provider).toBe("openai-codex");
|
|
56
|
+
expect(result.modelId).toBe("gpt-5.1-codex-max");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("unknown provider in provider/model format is returned verbatim", () => {
|
|
60
|
+
// resolveModelRef does NOT validate against a registry — it just parses
|
|
61
|
+
const result = resolveModelRef("future-provider/some-model-v99");
|
|
62
|
+
expect(result.provider).toBe("future-provider");
|
|
63
|
+
expect(result.modelId).toBe("some-model-v99");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("auto model for unknown provider returns the raw 'auto' string", () => {
|
|
67
|
+
// If DEFAULT_PROVIDER_MODELS doesn't have the provider, auto stays as-is
|
|
68
|
+
const result = resolveModelRef("unknown-provider-xyz/auto");
|
|
69
|
+
expect(result.provider).toBe("unknown-provider-xyz");
|
|
70
|
+
expect(result.modelId).toBe("auto");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("auto model for anthropic resolves to non-empty string", () => {
|
|
74
|
+
const result = resolveModelRef("anthropic/auto");
|
|
75
|
+
expect(result.provider).toBe("anthropic");
|
|
76
|
+
expect(result.modelId).toBeTruthy();
|
|
77
|
+
expect(result.modelId).not.toBe("auto");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("overrides.defaultModel takes priority over env var", () => {
|
|
81
|
+
process.env.AGENT_DEFAULT_MODEL = "google/gemini-2.5-pro";
|
|
82
|
+
const result = resolveModelRef("", {
|
|
83
|
+
defaultModel: "anthropic/claude-sonnet-4-20250514",
|
|
84
|
+
});
|
|
85
|
+
expect(result.provider).toBe("anthropic");
|
|
86
|
+
expect(result.modelId).toBe("claude-sonnet-4-20250514");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("overrides.defaultProvider takes priority over env var", () => {
|
|
90
|
+
process.env.AGENT_DEFAULT_PROVIDER = "google";
|
|
91
|
+
const result = resolveModelRef("some-model", {
|
|
92
|
+
defaultProvider: "openai",
|
|
93
|
+
});
|
|
94
|
+
expect(result.provider).toBe("openai");
|
|
95
|
+
expect(result.modelId).toBe("some-model");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("whitespace-only rawModelRef falls back to env vars", () => {
|
|
99
|
+
process.env.AGENT_DEFAULT_MODEL = "openai/gpt-4.1";
|
|
100
|
+
const result = resolveModelRef(" ");
|
|
101
|
+
expect(result.provider).toBe("openai");
|
|
102
|
+
expect(result.modelId).toBe("gpt-4.1");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("throws with descriptive message when no provider context exists", () => {
|
|
106
|
+
// Neither env var nor override
|
|
107
|
+
expect(() => resolveModelRef("just-a-model-name")).toThrow(
|
|
108
|
+
'No provider specified for model "just-a-model-name"'
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("throws when everything is empty and no defaults", () => {
|
|
113
|
+
expect(() => resolveModelRef("")).toThrow("No model configured");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("resolveModelRef — does not leak secrets", () => {
|
|
118
|
+
test("model ID containing lobu_secret placeholder is passed through unchanged", () => {
|
|
119
|
+
// Unlikely in practice but the resolver must not strip or transform it
|
|
120
|
+
const secretRef = "lobu_secret_abc";
|
|
121
|
+
const result = resolveModelRef(`test-provider/${secretRef}`);
|
|
122
|
+
expect(result.modelId).toBe(secretRef);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("registerDynamicProvider — idempotency and precedence", () => {
|
|
127
|
+
const id = `${PREFIX}-dynamic`;
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
delete DEFAULT_PROVIDER_BASE_URL_ENV[id];
|
|
131
|
+
delete DEFAULT_PROVIDER_MODELS[id];
|
|
132
|
+
delete PROVIDER_REGISTRY_ALIASES[id];
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("registering twice keeps first baseUrlEnvVar", () => {
|
|
136
|
+
registerDynamicProvider(id, { baseUrlEnvVar: "FIRST_URL" });
|
|
137
|
+
registerDynamicProvider(id, { baseUrlEnvVar: "SECOND_URL" });
|
|
138
|
+
expect(DEFAULT_PROVIDER_BASE_URL_ENV[id]).toBe("FIRST_URL");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("registering twice keeps first default model", () => {
|
|
142
|
+
registerDynamicProvider(id, {
|
|
143
|
+
baseUrlEnvVar: "URL",
|
|
144
|
+
defaultModel: "model-v1",
|
|
145
|
+
});
|
|
146
|
+
registerDynamicProvider(id, {
|
|
147
|
+
baseUrlEnvVar: "URL",
|
|
148
|
+
defaultModel: "model-v2",
|
|
149
|
+
});
|
|
150
|
+
expect(DEFAULT_PROVIDER_MODELS[id]).toBe("model-v1");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("explicit registryAlias is preferred over sdkCompat alias", () => {
|
|
154
|
+
registerDynamicProvider(id, {
|
|
155
|
+
baseUrlEnvVar: "URL",
|
|
156
|
+
sdkCompat: "openai",
|
|
157
|
+
registryAlias: "my-alias",
|
|
158
|
+
});
|
|
159
|
+
expect(PROVIDER_REGISTRY_ALIASES[id]).toBe("my-alias");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("no registryAlias entry when neither sdkCompat nor explicit alias given", () => {
|
|
163
|
+
registerDynamicProvider(id, { baseUrlEnvVar: "URL" });
|
|
164
|
+
expect(PROVIDER_REGISTRY_ALIASES[id]).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("registered dynamic provider is resolvable via resolveModelRef", () => {
|
|
168
|
+
registerDynamicProvider(id, {
|
|
169
|
+
baseUrlEnvVar: "MY_BASE_URL",
|
|
170
|
+
defaultModel: "dynamic-default",
|
|
171
|
+
});
|
|
172
|
+
const result = resolveModelRef("", { defaultProvider: id });
|
|
173
|
+
expect(result.provider).toBe(id);
|
|
174
|
+
expect(result.modelId).toBe("dynamic-default");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("DEFAULT_PROVIDER_MODELS completeness", () => {
|
|
179
|
+
const EXPECTED_PROVIDERS = [
|
|
180
|
+
"anthropic",
|
|
181
|
+
"openai",
|
|
182
|
+
"openai-codex",
|
|
183
|
+
"google",
|
|
184
|
+
"nvidia",
|
|
185
|
+
"z-ai",
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (const provider of EXPECTED_PROVIDERS) {
|
|
189
|
+
test(`provider "${provider}" has a non-empty default model`, () => {
|
|
190
|
+
expect(DEFAULT_PROVIDER_MODELS[provider]).toBeTruthy();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test(`provider "${provider}" has a base URL env var mapping`, () => {
|
|
194
|
+
expect(DEFAULT_PROVIDER_BASE_URL_ENV[provider]).toBeTruthy();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PROVIDER_BASE_URL_ENV,
|
|
4
|
+
DEFAULT_PROVIDER_MODELS,
|
|
5
|
+
PROVIDER_REGISTRY_ALIASES,
|
|
6
|
+
registerDynamicProvider,
|
|
7
|
+
resolveModelRef,
|
|
8
|
+
} from "../openclaw/model-resolver";
|
|
9
|
+
|
|
10
|
+
describe("resolveModelRef", () => {
|
|
11
|
+
let originalDefaultModel: string | undefined;
|
|
12
|
+
let originalDefaultProvider: string | undefined;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
originalDefaultModel = process.env.AGENT_DEFAULT_MODEL;
|
|
16
|
+
originalDefaultProvider = process.env.AGENT_DEFAULT_PROVIDER;
|
|
17
|
+
delete process.env.AGENT_DEFAULT_MODEL;
|
|
18
|
+
delete process.env.AGENT_DEFAULT_PROVIDER;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (originalDefaultModel !== undefined)
|
|
23
|
+
process.env.AGENT_DEFAULT_MODEL = originalDefaultModel;
|
|
24
|
+
else delete process.env.AGENT_DEFAULT_MODEL;
|
|
25
|
+
if (originalDefaultProvider !== undefined)
|
|
26
|
+
process.env.AGENT_DEFAULT_PROVIDER = originalDefaultProvider;
|
|
27
|
+
else delete process.env.AGENT_DEFAULT_PROVIDER;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("parses provider/model format", () => {
|
|
31
|
+
const result = resolveModelRef("anthropic/claude-sonnet-4-20250514");
|
|
32
|
+
expect(result.provider).toBe("anthropic");
|
|
33
|
+
expect(result.modelId).toBe("claude-sonnet-4-20250514");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("handles model with slashes (e.g. provider/org/model)", () => {
|
|
37
|
+
const result = resolveModelRef("openai/gpt-4.1");
|
|
38
|
+
expect(result.provider).toBe("openai");
|
|
39
|
+
expect(result.modelId).toBe("gpt-4.1");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("resolves 'auto' to provider default model", () => {
|
|
43
|
+
const result = resolveModelRef("anthropic/auto");
|
|
44
|
+
expect(result.provider).toBe("anthropic");
|
|
45
|
+
expect(result.modelId).toBe(DEFAULT_PROVIDER_MODELS.anthropic);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("uses AGENT_DEFAULT_PROVIDER for bare model ID", () => {
|
|
49
|
+
process.env.AGENT_DEFAULT_PROVIDER = "openai";
|
|
50
|
+
const result = resolveModelRef("gpt-4.1");
|
|
51
|
+
expect(result.provider).toBe("openai");
|
|
52
|
+
expect(result.modelId).toBe("gpt-4.1");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("falls back to AGENT_DEFAULT_MODEL when rawModelRef is empty", () => {
|
|
56
|
+
process.env.AGENT_DEFAULT_MODEL = "anthropic/claude-sonnet-4-20250514";
|
|
57
|
+
const result = resolveModelRef("");
|
|
58
|
+
expect(result.provider).toBe("anthropic");
|
|
59
|
+
expect(result.modelId).toBe("claude-sonnet-4-20250514");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("falls back to provider default when no model or AGENT_DEFAULT_MODEL", () => {
|
|
63
|
+
process.env.AGENT_DEFAULT_PROVIDER = "google";
|
|
64
|
+
const result = resolveModelRef("");
|
|
65
|
+
expect(result.provider).toBe("google");
|
|
66
|
+
expect(result.modelId).toBe(DEFAULT_PROVIDER_MODELS.google);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("throws when no model can be determined", () => {
|
|
70
|
+
expect(() => resolveModelRef("")).toThrow("No model configured");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("throws when bare model ID and no default provider", () => {
|
|
74
|
+
expect(() => resolveModelRef("some-model")).toThrow(
|
|
75
|
+
'No provider specified for model "some-model"'
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("trims whitespace from rawModelRef", () => {
|
|
80
|
+
const result = resolveModelRef(" anthropic/claude-sonnet-4-20250514 ");
|
|
81
|
+
expect(result.provider).toBe("anthropic");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("registerDynamicProvider", () => {
|
|
86
|
+
const testProviderId = `test-provider-${Date.now()}`;
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
// Clean up test provider entries
|
|
90
|
+
delete DEFAULT_PROVIDER_BASE_URL_ENV[testProviderId];
|
|
91
|
+
delete DEFAULT_PROVIDER_MODELS[testProviderId];
|
|
92
|
+
delete PROVIDER_REGISTRY_ALIASES[testProviderId];
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("registers new provider with baseUrlEnvVar", () => {
|
|
96
|
+
registerDynamicProvider(testProviderId, {
|
|
97
|
+
baseUrlEnvVar: "TEST_BASE_URL",
|
|
98
|
+
sdkCompat: "openai",
|
|
99
|
+
});
|
|
100
|
+
expect(DEFAULT_PROVIDER_BASE_URL_ENV[testProviderId]).toBe("TEST_BASE_URL");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("registers default model when provided", () => {
|
|
104
|
+
registerDynamicProvider(testProviderId, {
|
|
105
|
+
baseUrlEnvVar: "TEST_BASE_URL",
|
|
106
|
+
defaultModel: "test-model-v1",
|
|
107
|
+
});
|
|
108
|
+
expect(DEFAULT_PROVIDER_MODELS[testProviderId]).toBe("test-model-v1");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("sets registry alias for openai-compatible providers", () => {
|
|
112
|
+
registerDynamicProvider(testProviderId, {
|
|
113
|
+
baseUrlEnvVar: "TEST_BASE_URL",
|
|
114
|
+
sdkCompat: "openai",
|
|
115
|
+
});
|
|
116
|
+
expect(PROVIDER_REGISTRY_ALIASES[testProviderId]).toBe("openai");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("uses explicit registryAlias over sdkCompat", () => {
|
|
120
|
+
registerDynamicProvider(testProviderId, {
|
|
121
|
+
baseUrlEnvVar: "TEST_BASE_URL",
|
|
122
|
+
sdkCompat: "openai",
|
|
123
|
+
registryAlias: "custom",
|
|
124
|
+
});
|
|
125
|
+
expect(PROVIDER_REGISTRY_ALIASES[testProviderId]).toBe("custom");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("skips already-registered provider", () => {
|
|
129
|
+
DEFAULT_PROVIDER_BASE_URL_ENV[testProviderId] = "EXISTING";
|
|
130
|
+
registerDynamicProvider(testProviderId, {
|
|
131
|
+
baseUrlEnvVar: "NEW_VALUE",
|
|
132
|
+
});
|
|
133
|
+
expect(DEFAULT_PROVIDER_BASE_URL_ENV[testProviderId]).toBe("EXISTING");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("does not set alias when no sdkCompat or registryAlias", () => {
|
|
137
|
+
registerDynamicProvider(testProviderId, {
|
|
138
|
+
baseUrlEnvVar: "TEST_BASE_URL",
|
|
139
|
+
});
|
|
140
|
+
expect(PROVIDER_REGISTRY_ALIASES[testProviderId]).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("DEFAULT_PROVIDER_MODELS", () => {
|
|
145
|
+
test("contains expected providers", () => {
|
|
146
|
+
expect(DEFAULT_PROVIDER_MODELS.anthropic).toBeDefined();
|
|
147
|
+
expect(DEFAULT_PROVIDER_MODELS.openai).toBeDefined();
|
|
148
|
+
expect(DEFAULT_PROVIDER_MODELS.google).toBeDefined();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("DEFAULT_PROVIDER_BASE_URL_ENV", () => {
|
|
153
|
+
test("maps anthropic to ANTHROPIC_BASE_URL", () => {
|
|
154
|
+
expect(DEFAULT_PROVIDER_BASE_URL_ENV.anthropic).toBe("ANTHROPIC_BASE_URL");
|
|
155
|
+
});
|
|
156
|
+
});
|