@lobu/worker 6.1.1 → 7.1.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.
Files changed (124) hide show
  1. package/dist/core/error-handler.d.ts +0 -4
  2. package/dist/core/error-handler.d.ts.map +1 -1
  3. package/dist/core/error-handler.js +4 -15
  4. package/dist/core/error-handler.js.map +1 -1
  5. package/dist/core/types.d.ts +1 -19
  6. package/dist/core/types.d.ts.map +1 -1
  7. package/dist/core/types.js +0 -4
  8. package/dist/core/types.js.map +1 -1
  9. package/dist/core/workspace.d.ts +2 -11
  10. package/dist/core/workspace.d.ts.map +1 -1
  11. package/dist/core/workspace.js +14 -36
  12. package/dist/core/workspace.js.map +1 -1
  13. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  14. package/dist/embedded/just-bash-bootstrap.js +60 -6
  15. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  16. package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
  17. package/dist/embedded/mcp-cli-commands.js +3 -38
  18. package/dist/embedded/mcp-cli-commands.js.map +1 -1
  19. package/dist/gateway/gateway-integration.js +4 -4
  20. package/dist/gateway/gateway-integration.js.map +1 -1
  21. package/dist/gateway/message-batcher.d.ts.map +1 -1
  22. package/dist/gateway/message-batcher.js +3 -5
  23. package/dist/gateway/message-batcher.js.map +1 -1
  24. package/dist/gateway/sse-client.d.ts +1 -0
  25. package/dist/gateway/sse-client.d.ts.map +1 -1
  26. package/dist/gateway/sse-client.js +52 -8
  27. package/dist/gateway/sse-client.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +7 -24
  30. package/dist/index.js.map +1 -1
  31. package/dist/instructions/builder.d.ts.map +1 -1
  32. package/dist/instructions/builder.js +2 -1
  33. package/dist/instructions/builder.js.map +1 -1
  34. package/dist/openclaw/plugin-loader.d.ts.map +1 -1
  35. package/dist/openclaw/plugin-loader.js +8 -19
  36. package/dist/openclaw/plugin-loader.js.map +1 -1
  37. package/dist/openclaw/processor.d.ts.map +1 -1
  38. package/dist/openclaw/processor.js +2 -0
  39. package/dist/openclaw/processor.js.map +1 -1
  40. package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
  41. package/dist/openclaw/sandbox-leak.js +1 -6
  42. package/dist/openclaw/sandbox-leak.js.map +1 -1
  43. package/dist/openclaw/session-context.d.ts.map +1 -1
  44. package/dist/openclaw/session-context.js +3 -0
  45. package/dist/openclaw/session-context.js.map +1 -1
  46. package/dist/openclaw/tool-policy.d.ts.map +1 -1
  47. package/dist/openclaw/tool-policy.js +5 -11
  48. package/dist/openclaw/tool-policy.js.map +1 -1
  49. package/dist/openclaw/worker.d.ts +0 -1
  50. package/dist/openclaw/worker.d.ts.map +1 -1
  51. package/dist/openclaw/worker.js +19 -85
  52. package/dist/openclaw/worker.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +3 -40
  55. package/dist/server.js.map +1 -1
  56. package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
  57. package/dist/shared/audio-provider-suggestions.js +4 -6
  58. package/dist/shared/audio-provider-suggestions.js.map +1 -1
  59. package/dist/shared/tool-implementations.d.ts.map +1 -1
  60. package/dist/shared/tool-implementations.js +99 -37
  61. package/dist/shared/tool-implementations.js.map +1 -1
  62. package/package.json +14 -4
  63. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  64. package/src/__tests__/custom-tools.test.ts +92 -0
  65. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  66. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  67. package/src/__tests__/embedded-tools.test.ts +744 -0
  68. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  69. package/src/__tests__/exec-sandbox.test.ts +550 -0
  70. package/src/__tests__/generated-media.test.ts +142 -0
  71. package/src/__tests__/instructions.test.ts +60 -0
  72. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  73. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  74. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  75. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  76. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  77. package/src/__tests__/memory-flush.test.ts +64 -0
  78. package/src/__tests__/message-batcher.test.ts +247 -0
  79. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  80. package/src/__tests__/model-resolver.test.ts +156 -0
  81. package/src/__tests__/processor-harden.test.ts +259 -0
  82. package/src/__tests__/processor.test.ts +225 -0
  83. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  84. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  85. package/src/__tests__/sandbox-leak.test.ts +167 -0
  86. package/src/__tests__/setup.ts +102 -0
  87. package/src/__tests__/sse-client-harden.test.ts +588 -0
  88. package/src/__tests__/sse-client.test.ts +90 -0
  89. package/src/__tests__/tool-implementations.test.ts +196 -0
  90. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  91. package/src/__tests__/tool-policy.test.ts +269 -0
  92. package/src/__tests__/worker.test.ts +89 -0
  93. package/src/core/error-handler.ts +47 -0
  94. package/src/core/project-scanner.ts +65 -0
  95. package/src/core/types.ts +94 -0
  96. package/src/core/workspace.ts +66 -0
  97. package/src/embedded/exec-sandbox.ts +372 -0
  98. package/src/embedded/just-bash-bootstrap.ts +575 -0
  99. package/src/embedded/mcp-cli-commands.ts +405 -0
  100. package/src/gateway/gateway-integration.ts +298 -0
  101. package/src/gateway/message-batcher.ts +123 -0
  102. package/src/gateway/sse-client.ts +988 -0
  103. package/src/gateway/types.ts +68 -0
  104. package/src/index.ts +123 -0
  105. package/src/instructions/builder.ts +44 -0
  106. package/src/instructions/providers.ts +27 -0
  107. package/src/modules/lifecycle.ts +92 -0
  108. package/src/openclaw/custom-tools.ts +315 -0
  109. package/src/openclaw/instructions.ts +36 -0
  110. package/src/openclaw/model-resolver.ts +150 -0
  111. package/src/openclaw/plugin-loader.ts +423 -0
  112. package/src/openclaw/processor.ts +199 -0
  113. package/src/openclaw/sandbox-leak.ts +100 -0
  114. package/src/openclaw/session-context.ts +323 -0
  115. package/src/openclaw/tool-policy.ts +241 -0
  116. package/src/openclaw/tools.ts +277 -0
  117. package/src/openclaw/worker.ts +1836 -0
  118. package/src/server.ts +330 -0
  119. package/src/shared/audio-provider-suggestions.ts +130 -0
  120. package/src/shared/processor-utils.ts +33 -0
  121. package/src/shared/provider-auth-hints.ts +68 -0
  122. package/src/shared/tool-display-config.ts +75 -0
  123. package/src/shared/tool-implementations.ts +981 -0
  124. 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
+ });