@nordsym/apiclaw 1.2.10 → 1.3.1

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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * APIClaw Capability Router
3
+ * Routes capability requests to the best available provider
4
+ */
5
+
6
+ import { executeAPICall } from './execute.js';
7
+ import { logAPICall } from './analytics.js';
8
+
9
+ // Convex HTTP API for capability queries
10
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || 'https://adventurous-avocet-799.convex.cloud';
11
+
12
+ interface ProviderMapping {
13
+ providerId: string;
14
+ capabilityId: string;
15
+ priority: number;
16
+ regions: string[];
17
+ pricePerUnit: number;
18
+ currency: string;
19
+ avgLatencyMs: number;
20
+ paramMapping: Record<string, string>;
21
+ enabled: boolean;
22
+ healthStatus: string;
23
+ }
24
+
25
+ interface CapabilityPreferences {
26
+ region?: string;
27
+ maxPrice?: number;
28
+ preferredProvider?: string;
29
+ fallback?: boolean;
30
+ }
31
+
32
+ interface CapabilityResult {
33
+ success: boolean;
34
+ capability: string;
35
+ action: string;
36
+ providerUsed?: string;
37
+ fallbackAttempted: boolean;
38
+ fallbackReason?: string;
39
+ data?: unknown;
40
+ error?: string;
41
+ cost?: number;
42
+ currency?: string;
43
+ latencyMs?: number;
44
+ }
45
+
46
+ /**
47
+ * Query Convex for providers that support a capability
48
+ */
49
+ async function getProvidersForCapability(
50
+ capabilityId: string,
51
+ region?: string
52
+ ): Promise<ProviderMapping[]> {
53
+ try {
54
+ const res = await fetch(`${CONVEX_URL}/api/query`, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({
58
+ path: 'capabilities:getProviders',
59
+ args: { capabilityId, region },
60
+ }),
61
+ });
62
+
63
+ if (!res.ok) return [];
64
+
65
+ const data = await res.json() as { value?: ProviderMapping[] } | ProviderMapping[];
66
+ if (Array.isArray(data)) return data;
67
+ return (data.value || []) as ProviderMapping[];
68
+ } catch (e) {
69
+ console.error('Failed to fetch capability providers:', e);
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Map capability params to provider-specific params
76
+ */
77
+ function mapParams(
78
+ params: Record<string, unknown>,
79
+ mapping: Record<string, string>
80
+ ): Record<string, unknown> {
81
+ const result: Record<string, unknown> = {};
82
+
83
+ for (const [capParam, value] of Object.entries(params)) {
84
+ const providerParam = mapping[capParam] || capParam;
85
+ result[providerParam] = value;
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Log capability usage to Convex
93
+ */
94
+ async function logCapabilityUsage(params: {
95
+ capabilityId: string;
96
+ providerId: string;
97
+ userId: string;
98
+ action: string;
99
+ success: boolean;
100
+ fallbackUsed: boolean;
101
+ fallbackReason?: string;
102
+ latencyMs: number;
103
+ cost: number;
104
+ currency: string;
105
+ }): Promise<void> {
106
+ try {
107
+ await fetch(`${CONVEX_URL}/api/mutation`, {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({
111
+ path: 'capabilities:logUsage',
112
+ args: params,
113
+ }),
114
+ });
115
+ } catch (e) {
116
+ console.error('Failed to log capability usage:', e);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Execute a capability request with automatic provider selection and fallback
122
+ */
123
+ export async function executeCapability(
124
+ capabilityId: string,
125
+ action: string,
126
+ params: Record<string, unknown>,
127
+ userId: string,
128
+ preferences: CapabilityPreferences = {}
129
+ ): Promise<CapabilityResult> {
130
+ const startTime = Date.now();
131
+ const enableFallback = preferences.fallback !== false; // Default true
132
+
133
+ // Get providers for this capability
134
+ const providers = await getProvidersForCapability(capabilityId, preferences.region);
135
+
136
+ if (providers.length === 0) {
137
+ return {
138
+ success: false,
139
+ capability: capabilityId,
140
+ action,
141
+ fallbackAttempted: false,
142
+ error: `No providers available for capability: ${capabilityId}`,
143
+ };
144
+ }
145
+
146
+ // Filter by max price if specified
147
+ let filteredProviders = providers;
148
+ if (preferences.maxPrice !== undefined) {
149
+ filteredProviders = providers.filter(p => p.pricePerUnit <= preferences.maxPrice!);
150
+ }
151
+
152
+ // Prefer specific provider if requested
153
+ if (preferences.preferredProvider) {
154
+ const preferred = filteredProviders.find(p => p.providerId === preferences.preferredProvider);
155
+ if (preferred) {
156
+ filteredProviders = [preferred, ...filteredProviders.filter(p => p.providerId !== preferences.preferredProvider)];
157
+ }
158
+ }
159
+
160
+ if (filteredProviders.length === 0) {
161
+ return {
162
+ success: false,
163
+ capability: capabilityId,
164
+ action,
165
+ fallbackAttempted: false,
166
+ error: 'No providers match your preferences (region/price)',
167
+ };
168
+ }
169
+
170
+ // Try providers in order
171
+ let fallbackAttempted = false;
172
+ let lastError = '';
173
+
174
+ for (let i = 0; i < filteredProviders.length; i++) {
175
+ const provider = filteredProviders[i];
176
+ const isFirstAttempt = i === 0;
177
+
178
+ if (!isFirstAttempt) {
179
+ fallbackAttempted = true;
180
+ }
181
+
182
+ try {
183
+ // Map params to provider-specific format
184
+ const mappedParams = mapParams(params, provider.paramMapping || {});
185
+
186
+ // Execute via existing executeAPICall
187
+ const result = await executeAPICall(
188
+ provider.providerId,
189
+ action,
190
+ mappedParams,
191
+ userId
192
+ );
193
+
194
+ const latencyMs = Date.now() - startTime;
195
+
196
+ if (result.success) {
197
+ // Log successful usage
198
+ logCapabilityUsage({
199
+ capabilityId,
200
+ providerId: provider.providerId,
201
+ userId,
202
+ action,
203
+ success: true,
204
+ fallbackUsed: fallbackAttempted,
205
+ fallbackReason: fallbackAttempted ? lastError : undefined,
206
+ latencyMs,
207
+ cost: provider.pricePerUnit,
208
+ currency: provider.currency,
209
+ });
210
+
211
+ // Also log to file-based analytics
212
+ logAPICall({
213
+ timestamp: new Date().toISOString(),
214
+ provider: provider.providerId,
215
+ action,
216
+ type: 'direct',
217
+ userId,
218
+ success: true,
219
+ latencyMs,
220
+ });
221
+
222
+ return {
223
+ success: true,
224
+ capability: capabilityId,
225
+ action,
226
+ providerUsed: provider.providerId,
227
+ fallbackAttempted,
228
+ fallbackReason: fallbackAttempted ? lastError : undefined,
229
+ data: result.data,
230
+ cost: provider.pricePerUnit,
231
+ currency: provider.currency,
232
+ latencyMs,
233
+ };
234
+ }
235
+
236
+ // Provider returned error, try next
237
+ lastError = result.error || 'Unknown error';
238
+
239
+ if (!enableFallback) {
240
+ break;
241
+ }
242
+
243
+ } catch (e: any) {
244
+ lastError = e.message || 'Provider execution failed';
245
+
246
+ if (!enableFallback) {
247
+ break;
248
+ }
249
+ }
250
+ }
251
+
252
+ // All providers failed
253
+ const latencyMs = Date.now() - startTime;
254
+
255
+ logCapabilityUsage({
256
+ capabilityId,
257
+ providerId: filteredProviders[0].providerId,
258
+ userId,
259
+ action,
260
+ success: false,
261
+ fallbackUsed: fallbackAttempted,
262
+ fallbackReason: lastError,
263
+ latencyMs,
264
+ cost: 0,
265
+ currency: 'SEK',
266
+ });
267
+
268
+ return {
269
+ success: false,
270
+ capability: capabilityId,
271
+ action,
272
+ fallbackAttempted,
273
+ error: `All providers failed. Last error: ${lastError}`,
274
+ latencyMs,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * List available capabilities
280
+ */
281
+ export async function listCapabilities(): Promise<{ id: string; name: string; category: string }[]> {
282
+ try {
283
+ const res = await fetch(`${CONVEX_URL}/api/query`, {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json' },
286
+ body: JSON.stringify({
287
+ path: 'capabilities:list',
288
+ args: {},
289
+ }),
290
+ });
291
+
292
+ if (!res.ok) return [];
293
+
294
+ const data = await res.json() as { value?: any[] } | any[];
295
+ const capabilities = Array.isArray(data) ? data : (data.value || []);
296
+
297
+ return capabilities.map(c => ({
298
+ id: c.id,
299
+ name: c.name,
300
+ category: c.category,
301
+ }));
302
+ } catch (e) {
303
+ return [];
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Check if a capability exists
309
+ */
310
+ export async function hasCapability(capabilityId: string): Promise<boolean> {
311
+ try {
312
+ const res = await fetch(`${CONVEX_URL}/api/query`, {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({
316
+ path: 'capabilities:getById',
317
+ args: { id: capabilityId },
318
+ }),
319
+ });
320
+
321
+ if (!res.ok) return false;
322
+
323
+ const data = await res.json() as { value?: unknown } | unknown;
324
+ if (data && typeof data === 'object' && 'value' in data) {
325
+ return !!data.value;
326
+ }
327
+ return !!data;
328
+ } catch (e) {
329
+ return false;
330
+ }
331
+ }
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  generatePreview,
41
41
  validateParams
42
42
  } from './confirmation.js';
43
+ import { executeCapability, listCapabilities, hasCapability } from './capability-router.js';
43
44
 
44
45
  // Default agent ID for MVP (in production, this would come from auth)
45
46
  const DEFAULT_AGENT_ID = 'agent_default';
@@ -222,6 +223,46 @@ const tools: Tool[] = [
222
223
  type: 'object',
223
224
  properties: {}
224
225
  }
226
+ },
227
+ {
228
+ name: 'capability',
229
+ description: 'Execute an action by capability, not provider. APIClaw automatically selects the best provider, handles fallback, and optimizes for cost/speed. Example: capability("sms", "send", {to: "+46...", message: "Hello"})',
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ capability: {
234
+ type: 'string',
235
+ description: 'Capability ID: "sms", "email", "search", "tts", "invoice", "llm"'
236
+ },
237
+ action: {
238
+ type: 'string',
239
+ description: 'Action to perform: "send", "search", "generate", etc.'
240
+ },
241
+ params: {
242
+ type: 'object',
243
+ description: 'Parameters for the action (capability-standard params, not provider-specific)'
244
+ },
245
+ preferences: {
246
+ type: 'object',
247
+ description: 'Optional routing preferences',
248
+ properties: {
249
+ region: { type: 'string', description: 'Preferred region: "SE", "EU", "US"' },
250
+ maxPrice: { type: 'number', description: 'Max price per unit in cents/öre' },
251
+ preferredProvider: { type: 'string', description: 'Hint to prefer a specific provider' },
252
+ fallback: { type: 'boolean', description: 'Enable fallback to other providers (default: true)' }
253
+ }
254
+ }
255
+ },
256
+ required: ['capability', 'action', 'params']
257
+ }
258
+ },
259
+ {
260
+ name: 'list_capabilities',
261
+ description: 'List all available capabilities and their providers.',
262
+ inputSchema: {
263
+ type: 'object',
264
+ properties: {}
265
+ }
225
266
  }
226
267
  ];
227
268
 
@@ -664,6 +705,75 @@ Docs: https://apiclaw.nordsym.com
664
705
  };
665
706
  }
666
707
 
708
+ case 'capability': {
709
+ const capabilityId = args?.capability as string;
710
+ const action = args?.action as string;
711
+ const params = (args?.params as Record<string, any>) || {};
712
+ const preferences = (args?.preferences as Record<string, any>) || {};
713
+
714
+ // Check if capability exists
715
+ const exists = await hasCapability(capabilityId);
716
+ if (!exists) {
717
+ // Try to help with available capabilities
718
+ const available = await listCapabilities();
719
+ return {
720
+ content: [{
721
+ type: 'text',
722
+ text: JSON.stringify({
723
+ status: 'error',
724
+ error: `Unknown capability: ${capabilityId}`,
725
+ available_capabilities: available.map(c => c.id),
726
+ hint: 'Use list_capabilities to see all available capabilities.'
727
+ }, null, 2)
728
+ }],
729
+ isError: true
730
+ };
731
+ }
732
+
733
+ // Execute capability
734
+ const result = await executeCapability(
735
+ capabilityId,
736
+ action,
737
+ params,
738
+ DEFAULT_AGENT_ID,
739
+ preferences
740
+ );
741
+
742
+ return {
743
+ content: [{
744
+ type: 'text',
745
+ text: JSON.stringify({
746
+ status: result.success ? 'success' : 'error',
747
+ capability: result.capability,
748
+ action: result.action,
749
+ provider_used: result.providerUsed,
750
+ fallback_attempted: result.fallbackAttempted,
751
+ ...(result.fallbackReason ? { fallback_reason: result.fallbackReason } : {}),
752
+ ...(result.success ? { data: result.data } : { error: result.error }),
753
+ ...(result.cost !== undefined ? { cost: result.cost, currency: result.currency } : {}),
754
+ latency_ms: result.latencyMs,
755
+ }, null, 2)
756
+ }],
757
+ isError: !result.success
758
+ };
759
+ }
760
+
761
+ case 'list_capabilities': {
762
+ const capabilities = await listCapabilities();
763
+
764
+ return {
765
+ content: [{
766
+ type: 'text',
767
+ text: JSON.stringify({
768
+ status: 'success',
769
+ message: 'Available capabilities - use capability() to execute',
770
+ capabilities,
771
+ usage: 'capability("sms", "send", {to: "+46...", message: "Hello"})'
772
+ }, null, 2)
773
+ }]
774
+ };
775
+ }
776
+
667
777
  default:
668
778
  return {
669
779
  content: [
package/src/open-apis.ts CHANGED
@@ -128,6 +128,35 @@ export const openAPIs: Record<string, OpenAPIConfig> = {
128
128
  },
129
129
  },
130
130
  },
131
+
132
+ // Kroki - Diagrams as code (returns URL, not JSON)
133
+ kroki: {
134
+ name: 'Kroki',
135
+ description: 'Generate diagram URLs from text (Mermaid, PlantUML, GraphViz, C4)',
136
+ baseUrl: 'https://kroki.io',
137
+ actions: {
138
+ render: {
139
+ method: 'GET',
140
+ path: () => '', // Custom handling
141
+ transform: (_, params) => {
142
+ const type = params.type || 'mermaid';
143
+ const format = params.format || 'svg';
144
+ const diagram = params.diagram || '';
145
+
146
+ // Base64url encode the diagram
147
+ const encoded = Buffer.from(diagram).toString('base64url');
148
+ const url = `https://kroki.io/${type}/${format}/${encoded}`;
149
+
150
+ return {
151
+ url,
152
+ type,
153
+ format,
154
+ note: 'Open this URL to see/download the diagram',
155
+ };
156
+ },
157
+ },
158
+ },
159
+ },
131
160
  };
132
161
 
133
162
  /**
@@ -175,6 +204,38 @@ export async function executeOpenAPI(
175
204
  }
176
205
 
177
206
  try {
207
+ // Special handling for Kroki - no fetch needed, just compute URL
208
+ if (providerId === 'kroki') {
209
+ const type = params.type || 'mermaid';
210
+ const format = params.format || 'svg';
211
+ const diagram = params.diagram || '';
212
+
213
+ if (!diagram) {
214
+ return {
215
+ success: false,
216
+ provider: providerId,
217
+ action,
218
+ error: 'Missing required param: diagram',
219
+ };
220
+ }
221
+
222
+ const encoded = Buffer.from(diagram).toString('base64url');
223
+ const diagramUrl = `https://kroki.io/${type}/${format}/${encoded}`;
224
+
225
+ return {
226
+ success: true,
227
+ provider: providerId,
228
+ action,
229
+ data: {
230
+ url: diagramUrl,
231
+ type,
232
+ format,
233
+ supported_types: ['mermaid', 'plantuml', 'graphviz', 'c4plantuml', 'blockdiag', 'bpmn', 'excalidraw'],
234
+ supported_formats: ['svg', 'png', 'pdf'],
235
+ },
236
+ };
237
+ }
238
+
178
239
  const url = config.baseUrl + actionConfig.path(params);
179
240
  const response = await fetch(url, { method: actionConfig.method });
180
241