@blocks-network/mcp-server 0.1.62 → 0.1.65

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/src/tools.ts CHANGED
@@ -15,6 +15,12 @@ import {
15
15
  } from '@blocks-network/sdk';
16
16
 
17
17
  import type { ListAgentsResult } from './registry-list.js';
18
+ import {
19
+ MAX_AGENT_NAMES,
20
+ AGENT_NAME_PATTERN,
21
+ type AgentStatusResponse,
22
+ } from './agent-status.js';
23
+ import type { ConsumerBalance, TopUpSession } from './billing.js';
18
24
 
19
25
  // ============================================================================
20
26
  // Types — minimal local shapes mirroring @blocks-network/sdk surfaces.
@@ -85,6 +91,9 @@ export interface TaskClientLike {
85
91
  limit?: number;
86
92
  }): Promise<{ tasks: TaskInfo[]; totalCount?: number }>;
87
93
  cancelTask(taskId: string): Promise<unknown>;
94
+ pauseTask(taskId: string): Promise<unknown>;
95
+ resumeTask(taskId: string): Promise<unknown>;
96
+ retryTask(taskId: string): Promise<unknown>;
88
97
  connect(params: { taskId: string }): Promise<TaskSessionLike>;
89
98
  }
90
99
 
@@ -115,15 +124,37 @@ export interface ToolDeps {
115
124
  listAgents(options: {
116
125
  baseUrl: string;
117
126
  apiKey?: string;
118
- skill?: string;
127
+ /** Free-text search query (`q`); matches agent name, description, tags, etc. */
128
+ q?: string;
129
+ tag?: string;
119
130
  listing?: 'public' | 'private';
120
- limit?: number;
131
+ /** Optional cap on the total number of agents to fetch across all pages. */
132
+ maxAgents?: number;
121
133
  }): Promise<ListAgentsResult>;
122
134
  validateFilePath(filePath: string): string;
135
+ resolveSavePath(filePath: string): string;
136
+ writeFile(filePath: string, data: Uint8Array): void;
123
137
  fileSize(path: string): number;
124
138
  maxUploadBytes: number;
125
139
  filePartFromPath: typeof filePartFromPath;
126
140
  textPart: typeof textPart;
141
+ getOrgId(): string | undefined;
142
+ fetchAgentStatus(options: {
143
+ baseUrl: string;
144
+ apiKey?: string;
145
+ agentNames: string[];
146
+ }): Promise<AgentStatusResponse>;
147
+ getConsumerBalance(options: {
148
+ baseUrl: string;
149
+ apiKey: string;
150
+ orgId: string;
151
+ }): Promise<ConsumerBalance>;
152
+ createConsumerTopUp(options: {
153
+ baseUrl: string;
154
+ apiKey: string;
155
+ orgId: string;
156
+ amountUsd: number;
157
+ }): Promise<TopUpSession>;
127
158
  }
128
159
 
129
160
  // ============================================================================
@@ -196,9 +227,29 @@ export interface CancelTaskParams {
196
227
  }
197
228
 
198
229
  export interface ListAgentsParams {
199
- skill?: string;
230
+ tag?: string;
200
231
  listing?: 'public' | 'private';
201
232
  limit?: number;
233
+ /**
234
+ * Include agents that have no online instances. Defaults to false: the
235
+ * registry returns every registered agent regardless of whether any
236
+ * instance is currently running, so by default we filter to agents with
237
+ * at least one online instance to avoid surfacing unreachable agents.
238
+ */
239
+ includeOffline?: boolean;
240
+ }
241
+
242
+ export interface SearchAgentsParams {
243
+ /** Free-text search query; matches agent name, description, tags, etc. */
244
+ query: string;
245
+ tag?: string;
246
+ listing?: 'public' | 'private';
247
+ limit?: number;
248
+ /**
249
+ * Include agents that have no online instances. Defaults to false so we
250
+ * only surface agents that can actually take a task. See ListAgentsParams.
251
+ */
252
+ includeOffline?: boolean;
202
253
  }
203
254
 
204
255
  export interface GetAgentCardParams {
@@ -210,6 +261,24 @@ export interface ConnectTaskParams {
210
261
  timeoutMs?: number;
211
262
  }
212
263
 
264
+ export interface DownloadArtifactParams {
265
+ taskId: string;
266
+ fileName: string;
267
+ savePath?: string;
268
+ }
269
+
270
+ export interface TaskIdParams {
271
+ taskId: string;
272
+ }
273
+
274
+ export interface GetAgentStatusParams {
275
+ agentNames: string[];
276
+ }
277
+
278
+ export interface RequestTopUpParams {
279
+ amountUsd: number;
280
+ }
281
+
213
282
  // ============================================================================
214
283
  // send_task
215
284
  // ============================================================================
@@ -369,19 +438,108 @@ export async function listAgents(
369
438
  const result = await deps.listAgents({
370
439
  baseUrl,
371
440
  apiKey,
372
- skill: params.skill,
441
+ tag: params.tag,
373
442
  listing: params.listing,
374
- limit: params.limit,
443
+ maxAgents: params.limit,
375
444
  });
376
445
 
377
- const lines = result.agents.map((a) => {
378
- const skills = a.skills?.map((s) => s.name).join(', ') ?? '';
379
- return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${skills}`;
446
+ const total = result.totalCount ?? result.agents.length;
447
+
448
+ // The registry returns every registered agent regardless of whether any
449
+ // instance is currently running. Default to online-only so we never
450
+ // surface agents that can't actually take a task; `includeOffline` opts
451
+ // back into the full registry view.
452
+ if (params.includeOffline) {
453
+ const lines = result.agents.map(formatAgentRow);
454
+ const header = `Agents (${result.agents.length} of ${total} total):`;
455
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
456
+ }
457
+
458
+ const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
459
+ const lines = online.map(formatAgentRow);
460
+ const header = `Agents (${online.length} online of ${total} total):`;
461
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
462
+ }
463
+
464
+ // ============================================================================
465
+ // search_agent
466
+ // ============================================================================
467
+
468
+ export async function searchAgents(
469
+ params: SearchAgentsParams,
470
+ deps: ToolDeps,
471
+ ): Promise<ToolResult> {
472
+ const query = params.query.trim();
473
+ if (!query) {
474
+ return {
475
+ content: [{ type: 'text', text: 'Search query must not be empty.' }],
476
+ isError: true,
477
+ };
478
+ }
479
+
480
+ const baseUrl = await deps.getBaseUrl();
481
+ const apiKey = deps.getApiKey();
482
+ const result = await deps.listAgents({
483
+ baseUrl,
484
+ apiKey,
485
+ q: query,
486
+ tag: params.tag,
487
+ listing: params.listing,
488
+ maxAgents: params.limit,
380
489
  });
381
- const header = `Agents (${result.totalCount ?? result.agents.length}):`;
490
+
491
+ const total = result.totalCount ?? result.agents.length;
492
+
493
+ // Mirror list_agents: default to online-only so we never surface agents
494
+ // that can't actually take a task; `includeOffline` opts back into the
495
+ // full set of matches.
496
+ if (params.includeOffline) {
497
+ const lines = result.agents.map(formatAgentRow);
498
+ const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
499
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
500
+ }
501
+
502
+ const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
503
+ const lines = online.map(formatAgentRow);
504
+ const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
382
505
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
383
506
  }
384
507
 
508
+ function formatAgentRow(a: ListAgentsResult['agents'][number]): string {
509
+ const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
510
+ return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
511
+ }
512
+
513
+ /**
514
+ * Keep only agents that have at least one online instance, as reported by
515
+ * the agent-status service. Agent names that the status endpoint can't
516
+ * accept (too many, or invalid characters) are queried in valid batches;
517
+ * any name that's unqueryable is treated as offline and dropped.
518
+ */
519
+ async function filterOnlineAgents(
520
+ agents: ListAgentsResult['agents'],
521
+ ctx: { baseUrl: string; apiKey?: string },
522
+ deps: ToolDeps,
523
+ ): Promise<ListAgentsResult['agents']> {
524
+ const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
525
+ if (queryable.length === 0) return [];
526
+
527
+ const online = new Set<string>();
528
+ for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
529
+ const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
530
+ const status = await deps.fetchAgentStatus({
531
+ baseUrl: ctx.baseUrl,
532
+ apiKey: ctx.apiKey,
533
+ agentNames: batch.map((a) => a.agentName),
534
+ });
535
+ for (const [name, info] of Object.entries(status.agents)) {
536
+ if ((info.onlineCount ?? 0) > 0) online.add(name);
537
+ }
538
+ }
539
+
540
+ return agents.filter((a) => online.has(a.agentName));
541
+ }
542
+
385
543
  // ============================================================================
386
544
  // get_agent_card
387
545
  // ============================================================================
@@ -487,6 +645,207 @@ export async function connectTask(
487
645
  }
488
646
  }
489
647
 
648
+ // ============================================================================
649
+ // download_artifact
650
+ // ============================================================================
651
+
652
+ function bytesToBase64(bytes: Uint8Array): string {
653
+ let binary = '';
654
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
655
+ return Buffer.from(binary, 'binary').toString('base64');
656
+ }
657
+
658
+ export async function downloadArtifact(
659
+ params: DownloadArtifactParams,
660
+ deps: ToolDeps,
661
+ ): Promise<ToolResult> {
662
+ const freeClient = await deps.getTaskClient('free');
663
+ const task = await freeClient.getTask(params.taskId);
664
+ const billingMode = resolveBillingMode(task);
665
+ const client = await deps.getTaskClient(billingMode);
666
+
667
+ let session: TaskSessionLike;
668
+ try {
669
+ session = await client.connect({ taskId: params.taskId });
670
+ } catch (err) {
671
+ const msg = err instanceof Error ? err.message : String(err);
672
+ return {
673
+ content: [{ type: 'text', text: `Failed to connect to task ${params.taskId}: ${msg}` }],
674
+ isError: true,
675
+ };
676
+ }
677
+
678
+ try {
679
+ const refs = session.listArtifacts();
680
+ const ref = refs.find((r) => r.fileName === params.fileName);
681
+ if (!ref) {
682
+ const available = refs.map((r) => r.fileName ?? 'unnamed').join(', ') || '(none)';
683
+ return {
684
+ content: [
685
+ {
686
+ type: 'text',
687
+ text: `Artifact "${params.fileName}" not found on task ${params.taskId}. Available: ${available}`,
688
+ },
689
+ ],
690
+ isError: true,
691
+ };
692
+ }
693
+
694
+ const downloaded = await session.downloadArtifact(ref);
695
+
696
+ if (params.savePath) {
697
+ const safePath = deps.resolveSavePath(params.savePath);
698
+ deps.writeFile(safePath, downloaded.data);
699
+ return {
700
+ content: [
701
+ {
702
+ type: 'text',
703
+ text: `Saved ${downloaded.data.length} bytes to ${safePath} (${downloaded.mimeType})`,
704
+ },
705
+ ],
706
+ };
707
+ }
708
+
709
+ if (isTextMimeType(downloaded.mimeType)) {
710
+ const text = new TextDecoder().decode(downloaded.data);
711
+ return {
712
+ content: [
713
+ {
714
+ type: 'text',
715
+ text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)\n${text}`,
716
+ },
717
+ ],
718
+ };
719
+ }
720
+
721
+ return {
722
+ content: [
723
+ {
724
+ type: 'text',
725
+ text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes, base64)\n${bytesToBase64(downloaded.data)}`,
726
+ },
727
+ ],
728
+ };
729
+ } finally {
730
+ session.close();
731
+ }
732
+ }
733
+
734
+ // ============================================================================
735
+ // pause_task / resume_task / retry_task
736
+ // ============================================================================
737
+
738
+ export async function pauseTask(
739
+ params: TaskIdParams,
740
+ deps: ToolDeps,
741
+ ): Promise<ToolResult> {
742
+ const client = await deps.getTaskClient();
743
+ await client.pauseTask(params.taskId);
744
+ return { content: [{ type: 'text', text: `Task ${params.taskId} paused.` }] };
745
+ }
746
+
747
+ export async function resumeTask(
748
+ params: TaskIdParams,
749
+ deps: ToolDeps,
750
+ ): Promise<ToolResult> {
751
+ const client = await deps.getTaskClient();
752
+ await client.resumeTask(params.taskId);
753
+ return { content: [{ type: 'text', text: `Task ${params.taskId} resumed.` }] };
754
+ }
755
+
756
+ export async function retryTask(
757
+ params: TaskIdParams,
758
+ deps: ToolDeps,
759
+ ): Promise<ToolResult> {
760
+ const client = await deps.getTaskClient();
761
+ await client.retryTask(params.taskId);
762
+ return { content: [{ type: 'text', text: `Task ${params.taskId} retry requested.` }] };
763
+ }
764
+
765
+ // ============================================================================
766
+ // get_agent_status
767
+ // ============================================================================
768
+
769
+ export async function getAgentStatus(
770
+ params: GetAgentStatusParams,
771
+ deps: ToolDeps,
772
+ ): Promise<ToolResult> {
773
+ const baseUrl = await deps.getBaseUrl();
774
+ const apiKey = deps.getApiKey();
775
+ const result = await deps.fetchAgentStatus({
776
+ baseUrl,
777
+ apiKey,
778
+ agentNames: params.agentNames,
779
+ });
780
+ return {
781
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
782
+ };
783
+ }
784
+
785
+ // ============================================================================
786
+ // check_balance / request_topup
787
+ // ============================================================================
788
+
789
+ function requireBillingContext(deps: ToolDeps): { apiKey: string; orgId: string } | ToolResult {
790
+ const apiKey = deps.getApiKey();
791
+ if (!apiKey) {
792
+ return {
793
+ content: [{ type: 'text', text: 'BLOCKS_API_KEY is required for billing operations.' }],
794
+ isError: true,
795
+ };
796
+ }
797
+ const orgId = deps.getOrgId();
798
+ if (!orgId) {
799
+ return {
800
+ content: [
801
+ {
802
+ type: 'text',
803
+ text: 'BLOCKS_ORG_ID is required for billing operations. Set it from your dashboard URL or `blocks whoami --json`.',
804
+ },
805
+ ],
806
+ isError: true,
807
+ };
808
+ }
809
+ return { apiKey, orgId };
810
+ }
811
+
812
+ export async function checkBalance(
813
+ _params: Record<string, never>,
814
+ deps: ToolDeps,
815
+ ): Promise<ToolResult> {
816
+ const ctx = requireBillingContext(deps);
817
+ if ('content' in ctx) return ctx;
818
+ const baseUrl = await deps.getBaseUrl();
819
+ const balance = await deps.getConsumerBalance({
820
+ baseUrl,
821
+ apiKey: ctx.apiKey,
822
+ orgId: ctx.orgId,
823
+ });
824
+ return {
825
+ content: [{ type: 'text', text: JSON.stringify(balance, null, 2) }],
826
+ };
827
+ }
828
+
829
+ export async function requestTopup(
830
+ params: RequestTopUpParams,
831
+ deps: ToolDeps,
832
+ ): Promise<ToolResult> {
833
+ const ctx = requireBillingContext(deps);
834
+ if ('content' in ctx) return ctx;
835
+ const baseUrl = await deps.getBaseUrl();
836
+ const session = await deps.createConsumerTopUp({
837
+ baseUrl,
838
+ apiKey: ctx.apiKey,
839
+ orgId: ctx.orgId,
840
+ amountUsd: params.amountUsd,
841
+ });
842
+ const url = session.checkoutUrl;
843
+ const body = url
844
+ ? `Open this URL in a browser to complete the $${params.amountUsd.toFixed(2)} top-up:\n${url}`
845
+ : JSON.stringify(session, null, 2);
846
+ return { content: [{ type: 'text', text: body }] };
847
+ }
848
+
490
849
  // ============================================================================
491
850
  // Default file helpers (re-exported so index.ts can build a default ToolDeps)
492
851
  // ============================================================================
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { checkBalance, requestTopup } from '../src/tools.js';
3
+ import { getConsumerBalance, createConsumerTopUp } from '../src/billing.js';
4
+ import {
5
+ PROTOCOL_VERSION_HEADER,
6
+ CURRENT_PROTOCOL_VERSION,
7
+ } from '../src/protocol-headers.js';
8
+ import { makeFakeDeps } from './helpers.js';
9
+
10
+ function mockResponse(body: unknown, ok = true, status = 200): Response {
11
+ return {
12
+ ok,
13
+ status,
14
+ json: async () => body,
15
+ text: async () => JSON.stringify(body),
16
+ } as Response;
17
+ }
18
+
19
+ describe('check_balance (handler)', () => {
20
+ it('returns an error when BLOCKS_API_KEY is missing', async () => {
21
+ const { deps } = makeFakeDeps();
22
+
23
+ const res = await checkBalance({}, deps);
24
+
25
+ expect(res.isError).toBe(true);
26
+ expect(res.content[0].text).toMatch(/BLOCKS_API_KEY/);
27
+ });
28
+
29
+ it('returns an error when BLOCKS_ORG_ID is missing', async () => {
30
+ const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
31
+
32
+ const res = await checkBalance({}, deps);
33
+
34
+ expect(res.isError).toBe(true);
35
+ expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
36
+ });
37
+
38
+ it('forwards baseUrl, orgId, apiKey and emits balance JSON', async () => {
39
+ const balance = {
40
+ balance: '100.00',
41
+ reservedBalance: '5.00',
42
+ availableBalance: '95.00',
43
+ updatedAt: '2026-05-28T00:00:00Z',
44
+ };
45
+ const { deps, mocks } = makeFakeDeps({
46
+ apiKey: 'bk_test',
47
+ orgId: 'org_42',
48
+ balance,
49
+ });
50
+
51
+ const res = await checkBalance({}, deps);
52
+
53
+ expect(mocks.getConsumerBalance).toHaveBeenCalledWith({
54
+ baseUrl: 'http://api.test',
55
+ apiKey: 'bk_test',
56
+ orgId: 'org_42',
57
+ });
58
+ expect(JSON.parse(res.content[0].text)).toEqual(balance);
59
+ });
60
+ });
61
+
62
+ describe('request_topup (handler)', () => {
63
+ it('returns an error when BLOCKS_ORG_ID is missing', async () => {
64
+ const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
65
+
66
+ const res = await requestTopup({ amountUsd: 25 }, deps);
67
+
68
+ expect(res.isError).toBe(true);
69
+ expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
70
+ });
71
+
72
+ it('returns the Stripe checkout URL with a human-readable label', async () => {
73
+ const { deps, mocks } = makeFakeDeps({
74
+ apiKey: 'bk_test',
75
+ orgId: 'org_42',
76
+ topUpSession: {
77
+ checkoutUrl: 'https://checkout.stripe.test/session/xyz',
78
+ sessionId: 'cs_test_abc',
79
+ },
80
+ });
81
+
82
+ const res = await requestTopup({ amountUsd: 25 }, deps);
83
+
84
+ expect(mocks.createConsumerTopUp).toHaveBeenCalledWith({
85
+ baseUrl: 'http://api.test',
86
+ apiKey: 'bk_test',
87
+ orgId: 'org_42',
88
+ amountUsd: 25,
89
+ });
90
+ expect(res.content[0].text).toContain('https://checkout.stripe.test/session/xyz');
91
+ expect(res.content[0].text).toContain('$25.00');
92
+ });
93
+ });
94
+
95
+ describe('getConsumerBalance (HTTP helper)', () => {
96
+ it('GETs /api/v1/billing/:orgId/consumer/balance with Bearer auth', async () => {
97
+ const fetchImpl = vi
98
+ .fn()
99
+ .mockResolvedValue(
100
+ mockResponse({
101
+ balance: '0',
102
+ reservedBalance: '0',
103
+ availableBalance: '0',
104
+ updatedAt: '2026-05-28T00:00:00Z',
105
+ }),
106
+ );
107
+
108
+ await getConsumerBalance({
109
+ baseUrl: 'http://api.test',
110
+ orgId: 'org_42',
111
+ apiKey: 'bk_test',
112
+ fetchImpl: fetchImpl as unknown as typeof fetch,
113
+ });
114
+
115
+ const [url, init] = fetchImpl.mock.calls[0];
116
+ const parsed = new URL(String(url));
117
+ expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/balance');
118
+ expect(init?.method).toBe('GET');
119
+ const headers = init?.headers as Record<string, string>;
120
+ expect(headers.Authorization).toBe('Bearer bk_test');
121
+ expect(headers[PROTOCOL_VERSION_HEADER]).toBe(CURRENT_PROTOCOL_VERSION);
122
+ });
123
+
124
+ it('throws on non-OK responses', async () => {
125
+ const fetchImpl = vi.fn().mockResolvedValue(mockResponse({}, false, 403));
126
+
127
+ await expect(
128
+ getConsumerBalance({
129
+ baseUrl: 'http://api.test',
130
+ orgId: 'org_42',
131
+ apiKey: 'bk_test',
132
+ fetchImpl: fetchImpl as unknown as typeof fetch,
133
+ }),
134
+ ).rejects.toThrow('HTTP 403');
135
+ });
136
+ });
137
+
138
+ describe('createConsumerTopUp (HTTP helper)', () => {
139
+ it('POSTs JSON body with whole-cent amount string', async () => {
140
+ const fetchImpl = vi
141
+ .fn()
142
+ .mockResolvedValue(
143
+ mockResponse({
144
+ checkoutUrl: 'https://checkout.stripe.test/x',
145
+ sessionId: 'cs_test_x',
146
+ }),
147
+ );
148
+
149
+ await createConsumerTopUp({
150
+ baseUrl: 'http://api.test',
151
+ orgId: 'org_42',
152
+ apiKey: 'bk_test',
153
+ amountUsd: 19.99,
154
+ fetchImpl: fetchImpl as unknown as typeof fetch,
155
+ });
156
+
157
+ const [url, init] = fetchImpl.mock.calls[0];
158
+ const parsed = new URL(String(url));
159
+ expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/topup');
160
+ expect(init?.method).toBe('POST');
161
+ expect(JSON.parse(String(init?.body))).toEqual({ amount: '19.99' });
162
+ const headers = init?.headers as Record<string, string>;
163
+ expect(headers['Content-Type']).toBe('application/json');
164
+ });
165
+
166
+ it('rejects non-positive amounts', async () => {
167
+ await expect(
168
+ createConsumerTopUp({
169
+ baseUrl: 'http://api.test',
170
+ orgId: 'org_42',
171
+ apiKey: 'bk_test',
172
+ amountUsd: 0,
173
+ }),
174
+ ).rejects.toThrow(/positive/);
175
+ });
176
+
177
+ it('rejects amounts below the $5 platform minimum', async () => {
178
+ await expect(
179
+ createConsumerTopUp({
180
+ baseUrl: 'http://api.test',
181
+ orgId: 'org_42',
182
+ apiKey: 'bk_test',
183
+ amountUsd: 1,
184
+ }),
185
+ ).rejects.toThrow(/at least \$5/);
186
+ });
187
+
188
+ it('throws on non-OK responses with body detail', async () => {
189
+ const fetchImpl = vi
190
+ .fn()
191
+ .mockResolvedValue(mockResponse({ error: 'amount too small' }, false, 400));
192
+
193
+ await expect(
194
+ createConsumerTopUp({
195
+ baseUrl: 'http://api.test',
196
+ orgId: 'org_42',
197
+ apiKey: 'bk_test',
198
+ amountUsd: 25,
199
+ fetchImpl: fetchImpl as unknown as typeof fetch,
200
+ }),
201
+ ).rejects.toThrow(/HTTP 400/);
202
+ });
203
+ });