@forgezero/providers 0.1.8 → 0.1.10

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/email.js CHANGED
@@ -38,25 +38,45 @@ function chainCredentials(...sources) {
38
38
  }
39
39
  };
40
40
  }
41
- function staticConfig(services) {
41
+ function staticConfig(config) {
42
42
  const health = new Map;
43
43
  return {
44
44
  name: "static",
45
- async list(serviceKey) {
46
- return (services[serviceKey] ?? []).map((provider) => ({
47
- ...provider,
48
- health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
45
+ async provider(instanceKey) {
46
+ const provider = config.providers[instanceKey];
47
+ return provider ? { instanceKey, ...provider, config: { ...provider.config } } : undefined;
48
+ },
49
+ async list(serviceKey, methodKey) {
50
+ return (config.services[serviceKey]?.[methodKey] ?? []).map((attachment) => ({
51
+ ...attachment,
52
+ health: health.get(`${serviceKey}:${methodKey}:${attachment.instanceKey}:${attachment.providerMethod}`) ?? attachment.health
49
53
  }));
50
54
  },
51
- async recordHealth(serviceKey, providerId, next) {
52
- health.set(`${serviceKey}:${providerId}`, next);
55
+ async recordHealth(serviceKey, methodKey, instanceKey, providerMethod, next) {
56
+ health.set(`${serviceKey}:${methodKey}:${instanceKey}:${providerMethod}`, next);
53
57
  }
54
58
  };
55
59
  }
60
+ function defineProviderMethod(method) {
61
+ return method;
62
+ }
56
63
  function defineProvider(spec) {
57
64
  return spec;
58
65
  }
66
+ function defineSingleMethodProvider(spec) {
67
+ const { method, invoke, classify, ...identity } = spec;
68
+ return defineProvider({
69
+ ...identity,
70
+ methods: { [method]: defineProviderMethod({ invoke, classify }) }
71
+ });
72
+ }
59
73
  var STRIKES_TO_OFFLINE = 3;
74
+ function serviceMethod() {
75
+ return Object.freeze({});
76
+ }
77
+ function defineService(definition) {
78
+ return definition;
79
+ }
60
80
  function nextHealth(current, kind) {
61
81
  if (kind === "success")
62
82
  return { strikes: 0, status: "ok" };
@@ -71,80 +91,117 @@ function nextHealth(current, kind) {
71
91
  }
72
92
  function createRegistry(options) {
73
93
  const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
- async function call(serviceKey, args, callOptions = {}) {
75
- const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
94
+ async function call(serviceKey, methodKey, args, callOptions = {}) {
95
+ const configured = [...await options.config.list(serviceKey, methodKey)].filter((attachment) => attachment.enabled).sort((a, b) => a.priority - b.priority);
76
96
  const attempts = [];
77
- for (const entry of configured) {
97
+ for (const attachment of configured) {
78
98
  if (callOptions.signal?.aborted) {
79
99
  const cancelled = {
80
100
  ok: false,
101
+ fallbackUsed: attempts.length > 0,
81
102
  attempts,
82
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
103
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
83
104
  };
84
- options.after?.(cancelled);
105
+ await options.after?.(cancelled);
85
106
  return cancelled;
86
107
  }
87
- const spec = byId.get(entry.providerId);
88
- if (!spec) {
89
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
108
+ const instance = await options.config.provider(attachment.instanceKey);
109
+ if (!instance) {
110
+ attempts.push({ providerId: "unknown", instanceKey: attachment.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance not registered" });
111
+ continue;
112
+ }
113
+ const spec = byId.get(instance.providerId);
114
+ const method = spec?.methods[attachment.providerMethod];
115
+ if (!spec || !method) {
116
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: !spec ? "provider not registered" : "method not supported" });
90
117
  continue;
91
118
  }
92
- if (entry.health?.status === "offline") {
93
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
119
+ if (!instance.enabled) {
120
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance disabled" });
94
121
  continue;
95
122
  }
96
- options.before?.({ service: serviceKey, provider: entry.providerId });
123
+ if (attachment.health?.status === "offline") {
124
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "offline" });
125
+ continue;
126
+ }
127
+ await options.before?.({ service: serviceKey, method: methodKey, provider: instance.providerId, instance: instance.instanceKey });
128
+ const startedAt = performance.now();
97
129
  try {
98
- const result = await spec.invoke({
99
- config: entry.config,
100
- secret: (field) => options.credentials.get(entry.secretRef, field),
130
+ const result = await method.invoke({
131
+ config: instance.config,
132
+ secret: (field) => options.credentials.get(instance.secretRef, field),
101
133
  signal: callOptions.signal
102
134
  }, args);
103
- attempts.push({ providerId: entry.providerId, outcome: "sent" });
104
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
105
- const sent = { ok: true, result, provider: entry.providerId, attempts };
106
- options.after?.(sent);
135
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "sent", durationMs: performance.now() - startedAt });
136
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, "success"));
137
+ const sent = {
138
+ ok: true,
139
+ result,
140
+ provider: instance.providerId,
141
+ instance: instance.instanceKey,
142
+ method: attachment.providerMethod,
143
+ selected: { providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod },
144
+ fallbackUsed: attempts.length > 1,
145
+ attempts
146
+ };
147
+ await options.after?.(sent);
107
148
  return sent;
108
149
  } catch (error) {
109
150
  if (callOptions.signal?.aborted) {
110
151
  const cancelled = {
111
152
  ok: false,
153
+ fallbackUsed: attempts.length > 0,
112
154
  attempts,
113
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
155
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
114
156
  };
115
- options.after?.(cancelled);
157
+ await options.after?.(cancelled);
116
158
  return cancelled;
117
159
  }
118
- const kind = spec.classify(error);
160
+ const kind = method.classify(error);
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ const code = error instanceof ProviderError ? error.code : typeof error?.code === "string" ? error.code : undefined;
119
163
  attempts.push({
120
- providerId: entry.providerId,
164
+ providerId: instance.providerId,
165
+ instanceKey: instance.instanceKey,
166
+ providerMethod: attachment.providerMethod,
121
167
  outcome: "failed",
122
168
  kind,
123
- error: error instanceof Error ? error.message : String(error)
169
+ durationMs: performance.now() - startedAt,
170
+ failure: { kind, ...code ? { code } : {}, message },
171
+ error: message
124
172
  });
125
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
173
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, kind));
126
174
  if (kind === "terminal") {
127
175
  const refused = {
128
176
  ok: false,
177
+ fallbackUsed: attempts.length > 1,
129
178
  attempts,
130
179
  error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
131
180
  };
132
- options.after?.(refused);
181
+ await options.after?.(refused);
133
182
  return refused;
134
183
  }
135
184
  }
136
185
  }
137
186
  const failed = {
138
187
  ok: false,
188
+ fallbackUsed: attempts.length > 1,
139
189
  attempts,
140
- error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
190
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider method is configured for "${serviceKey}.${methodKey}".` : `Every provider method for "${serviceKey}.${methodKey}" failed or was skipped.`)
141
191
  };
142
- options.after?.(failed);
192
+ await options.after?.(failed);
143
193
  return failed;
144
194
  }
145
- return { call };
195
+ function service(definition) {
196
+ return {
197
+ call(method, args, callOptions) {
198
+ return call(definition.key, method, args, callOptions);
199
+ }
200
+ };
201
+ }
202
+ return { call, service };
146
203
  }
147
- var VERSION = "0.1.8";
204
+ var VERSION = "0.1.10";
148
205
 
149
206
  // src/email.ts
150
207
  var recipients = (to) => Array.isArray(to) ? [...to] : [to];
@@ -156,31 +213,28 @@ function assertMessage(message) {
156
213
  throw new ProviderError("EMAIL_NO_BODY", "An email needs html, text, or both.");
157
214
  }
158
215
  }
159
- var jetemail = defineProvider({
160
- id: "jetemail",
161
- service: "email",
162
- label: "JetEmail",
163
- credentials: {
164
- type: "object",
165
- additionalProperties: false,
166
- required: ["apiKey"],
167
- properties: {
168
- apiKey: {
169
- type: "string",
170
- title: "API key",
171
- writeOnly: true,
172
- description: "Transactional key from the JetEmail dashboard."
173
- }
216
+ var jetemailCredentials = {
217
+ type: "object",
218
+ additionalProperties: false,
219
+ required: ["apiKey"],
220
+ properties: {
221
+ apiKey: {
222
+ type: "string",
223
+ title: "API key",
224
+ writeOnly: true,
225
+ description: "Transactional key from the JetEmail dashboard."
174
226
  }
175
- },
176
- config: {
177
- type: "object",
178
- additionalProperties: false,
179
- properties: {
180
- eu: { type: "boolean", default: false, title: "EU residency" },
181
- from: { type: "string", format: "email", title: "Default from address" }
182
- }
183
- },
227
+ }
228
+ };
229
+ var jetemailConfig = {
230
+ type: "object",
231
+ additionalProperties: false,
232
+ properties: {
233
+ eu: { type: "boolean", default: false, title: "EU residency" },
234
+ from: { type: "string", format: "email", title: "Default from address" }
235
+ }
236
+ };
237
+ var jetemailSend = defineProviderMethod({
184
238
  async invoke(context, message) {
185
239
  assertMessage(message);
186
240
  const config = context.config;
@@ -191,7 +245,8 @@ var jetemail = defineProvider({
191
245
  signal: context.signal,
192
246
  headers: {
193
247
  "content-type": "application/json",
194
- authorization: `Bearer ${await context.secret("apiKey")}`
248
+ authorization: `Bearer ${await context.secret("apiKey")}`,
249
+ ...message.idempotencyKey ? { "idempotency-key": message.idempotencyKey } : {}
195
250
  },
196
251
  body: JSON.stringify({
197
252
  from: message.from ?? config.from,
@@ -222,35 +277,78 @@ var jetemail = defineProvider({
222
277
  return "retryable";
223
278
  }
224
279
  });
225
- var smtp = defineProvider({
226
- id: "smtp",
227
- service: "email",
228
- label: "SMTP",
229
- multiInstance: true,
230
- credentials: {
231
- type: "object",
232
- additionalProperties: false,
233
- properties: {
234
- user: { type: "string", title: "Username" },
235
- password: { type: "string", title: "Password", writeOnly: true }
236
- }
237
- },
238
- config: {
239
- type: "object",
240
- additionalProperties: false,
241
- required: ["host", "from"],
242
- properties: {
243
- host: { type: "string", title: "Host" },
244
- port: { type: "integer", default: 587, minimum: 1, maximum: 65535 },
245
- secure: {
246
- type: "boolean",
247
- default: false,
248
- title: "Implicit TLS",
249
- description: "True for port 465. Port 587 upgrades with STARTTLS and should stay false."
280
+ function assertBatch(batch) {
281
+ if (!Array.isArray(batch.emails) || batch.emails.length < 1 || batch.emails.length > 100) {
282
+ throw new ProviderError("EMAIL_BATCH_SIZE", "An email batch must contain between 1 and 100 messages.");
283
+ }
284
+ for (const message of batch.emails)
285
+ assertMessage(message);
286
+ }
287
+ var jetemailSendBatch = defineProviderMethod({
288
+ async invoke(context, batch) {
289
+ assertBatch(batch);
290
+ const config = context.config;
291
+ const doFetch = config.fetch ?? globalThis.fetch;
292
+ const response = await doFetch(config.batchEndpoint ?? "https://api.jetemail.com/email-batch", {
293
+ method: "POST",
294
+ signal: context.signal,
295
+ headers: {
296
+ "content-type": "application/json",
297
+ authorization: `Bearer ${await context.secret("apiKey")}`
250
298
  },
251
- from: { type: "string", format: "email", title: "From address" }
299
+ body: JSON.stringify({
300
+ emails: batch.emails.map((message) => ({
301
+ from: message.from ?? config.from,
302
+ to: recipients(message.to),
303
+ subject: message.subject,
304
+ html: message.html,
305
+ text: message.text,
306
+ reply_to: message.replyTo,
307
+ eu: config.eu ?? false
308
+ }))
309
+ })
310
+ });
311
+ if (response.status !== 207) {
312
+ const detail = await response.text().catch(() => "");
313
+ throw Object.assign(new Error(`JetEmail batch ${response.status}: ${detail.slice(0, 200)}`), {
314
+ status: response.status,
315
+ retryAfter: response.headers.get("retry-after")
316
+ });
317
+ }
318
+ const payload = await response.json().catch(() => null);
319
+ if (!payload?.summary || !Array.isArray(payload.results) || ![payload.summary.total, payload.summary.successful, payload.summary.failed].every((value) => Number.isInteger(value) && value >= 0)) {
320
+ throw new ProviderError("JETEMAIL_BATCH_RESPONSE", "JetEmail returned a malformed batch response.");
252
321
  }
322
+ const summary = payload.summary;
323
+ if (summary.total !== batch.emails.length || summary.successful + summary.failed !== summary.total) {
324
+ throw new ProviderError("JETEMAIL_BATCH_RESPONSE", "JetEmail batch totals do not match the request.");
325
+ }
326
+ const results = payload.results.map((item) => {
327
+ if (!item || typeof item !== "object")
328
+ return { status: "failed", error: "Malformed provider result" };
329
+ const row = item;
330
+ return {
331
+ status: row.status === "success" ? "success" : "failed",
332
+ ...typeof row.id === "string" ? { id: row.id } : {},
333
+ ...typeof row.response === "string" ? { response: row.response.slice(0, 500) } : {},
334
+ ...typeof row.error === "string" ? { error: row.error.slice(0, 500) } : {}
335
+ };
336
+ });
337
+ return { summary, results };
253
338
  },
339
+ classify: jetemailSend.classify
340
+ });
341
+ var jetemail = defineProvider({
342
+ id: "jetemail",
343
+ label: "JetEmail",
344
+ credentials: jetemailCredentials,
345
+ config: jetemailConfig,
346
+ methods: {
347
+ send: jetemailSend,
348
+ sendBatch: jetemailSendBatch
349
+ }
350
+ });
351
+ var smtpSend = defineProviderMethod({
254
352
  async invoke(context, message) {
255
353
  assertMessage(message);
256
354
  const config = context.config;
@@ -289,9 +387,47 @@ var smtp = defineProvider({
289
387
  return "retryable";
290
388
  }
291
389
  });
390
+ var smtp = defineProvider({
391
+ id: "smtp",
392
+ label: "SMTP",
393
+ multiInstance: true,
394
+ credentials: {
395
+ type: "object",
396
+ additionalProperties: false,
397
+ properties: {
398
+ user: { type: "string", title: "Username" },
399
+ password: { type: "string", title: "Password", writeOnly: true }
400
+ }
401
+ },
402
+ config: {
403
+ type: "object",
404
+ additionalProperties: false,
405
+ required: ["host", "from"],
406
+ properties: {
407
+ host: { type: "string", title: "Host" },
408
+ port: { type: "integer", default: 587, minimum: 1, maximum: 65535 },
409
+ secure: {
410
+ type: "boolean",
411
+ default: false,
412
+ title: "Implicit TLS",
413
+ description: "True for port 465. Port 587 upgrades with STARTTLS and should stay false."
414
+ },
415
+ from: { type: "string", format: "email", title: "From address" }
416
+ }
417
+ },
418
+ methods: { send: smtpSend }
419
+ });
292
420
  var emailProviders = [jetemail, smtp];
421
+ var emailService = defineService({
422
+ key: "email",
423
+ methods: {
424
+ send: serviceMethod(),
425
+ sendBatch: serviceMethod()
426
+ }
427
+ });
293
428
  export {
294
429
  smtp,
295
430
  jetemail,
431
+ emailService,
296
432
  emailProviders
297
433
  };
package/dist/http.d.ts CHANGED
@@ -93,8 +93,8 @@ export type HttpClient = ReturnType<typeof createHttpClient>;
93
93
  * `classify` is where the venue-specific knowledge lives, and it is the part
94
94
  * that decides whether a retry helps or makes things worse.
95
95
  */
96
- export declare const http: import("./index").ProviderSpec<HttpRequest, HttpResponse<unknown>>;
96
+ export declare const http: import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<HttpRequest, HttpResponse<unknown>>>>;
97
97
  /** Binance reports a running total for the window, not a per-call cost. */
98
98
  export declare const binanceWeight: (response: Response) => number | undefined;
99
- export declare const httpProviders: readonly [import("./index").ProviderSpec<HttpRequest, HttpResponse<unknown>>];
99
+ export declare const httpProviders: readonly [import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<HttpRequest, HttpResponse<unknown>>>>];
100
100
  export {};
package/dist/http.js CHANGED
@@ -38,25 +38,45 @@ function chainCredentials(...sources) {
38
38
  }
39
39
  };
40
40
  }
41
- function staticConfig(services) {
41
+ function staticConfig(config) {
42
42
  const health = new Map;
43
43
  return {
44
44
  name: "static",
45
- async list(serviceKey) {
46
- return (services[serviceKey] ?? []).map((provider) => ({
47
- ...provider,
48
- health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
45
+ async provider(instanceKey) {
46
+ const provider = config.providers[instanceKey];
47
+ return provider ? { instanceKey, ...provider, config: { ...provider.config } } : undefined;
48
+ },
49
+ async list(serviceKey, methodKey) {
50
+ return (config.services[serviceKey]?.[methodKey] ?? []).map((attachment) => ({
51
+ ...attachment,
52
+ health: health.get(`${serviceKey}:${methodKey}:${attachment.instanceKey}:${attachment.providerMethod}`) ?? attachment.health
49
53
  }));
50
54
  },
51
- async recordHealth(serviceKey, providerId, next) {
52
- health.set(`${serviceKey}:${providerId}`, next);
55
+ async recordHealth(serviceKey, methodKey, instanceKey, providerMethod, next) {
56
+ health.set(`${serviceKey}:${methodKey}:${instanceKey}:${providerMethod}`, next);
53
57
  }
54
58
  };
55
59
  }
60
+ function defineProviderMethod(method) {
61
+ return method;
62
+ }
56
63
  function defineProvider(spec) {
57
64
  return spec;
58
65
  }
66
+ function defineSingleMethodProvider(spec) {
67
+ const { method, invoke, classify, ...identity } = spec;
68
+ return defineProvider({
69
+ ...identity,
70
+ methods: { [method]: defineProviderMethod({ invoke, classify }) }
71
+ });
72
+ }
59
73
  var STRIKES_TO_OFFLINE = 3;
74
+ function serviceMethod() {
75
+ return Object.freeze({});
76
+ }
77
+ function defineService(definition) {
78
+ return definition;
79
+ }
60
80
  function nextHealth(current, kind) {
61
81
  if (kind === "success")
62
82
  return { strikes: 0, status: "ok" };
@@ -71,80 +91,117 @@ function nextHealth(current, kind) {
71
91
  }
72
92
  function createRegistry(options) {
73
93
  const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
- async function call(serviceKey, args, callOptions = {}) {
75
- const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
94
+ async function call(serviceKey, methodKey, args, callOptions = {}) {
95
+ const configured = [...await options.config.list(serviceKey, methodKey)].filter((attachment) => attachment.enabled).sort((a, b) => a.priority - b.priority);
76
96
  const attempts = [];
77
- for (const entry of configured) {
97
+ for (const attachment of configured) {
78
98
  if (callOptions.signal?.aborted) {
79
99
  const cancelled = {
80
100
  ok: false,
101
+ fallbackUsed: attempts.length > 0,
81
102
  attempts,
82
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
103
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
83
104
  };
84
- options.after?.(cancelled);
105
+ await options.after?.(cancelled);
85
106
  return cancelled;
86
107
  }
87
- const spec = byId.get(entry.providerId);
88
- if (!spec) {
89
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
108
+ const instance = await options.config.provider(attachment.instanceKey);
109
+ if (!instance) {
110
+ attempts.push({ providerId: "unknown", instanceKey: attachment.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance not registered" });
111
+ continue;
112
+ }
113
+ const spec = byId.get(instance.providerId);
114
+ const method = spec?.methods[attachment.providerMethod];
115
+ if (!spec || !method) {
116
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: !spec ? "provider not registered" : "method not supported" });
90
117
  continue;
91
118
  }
92
- if (entry.health?.status === "offline") {
93
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
119
+ if (!instance.enabled) {
120
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance disabled" });
94
121
  continue;
95
122
  }
96
- options.before?.({ service: serviceKey, provider: entry.providerId });
123
+ if (attachment.health?.status === "offline") {
124
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "offline" });
125
+ continue;
126
+ }
127
+ await options.before?.({ service: serviceKey, method: methodKey, provider: instance.providerId, instance: instance.instanceKey });
128
+ const startedAt = performance.now();
97
129
  try {
98
- const result = await spec.invoke({
99
- config: entry.config,
100
- secret: (field) => options.credentials.get(entry.secretRef, field),
130
+ const result = await method.invoke({
131
+ config: instance.config,
132
+ secret: (field) => options.credentials.get(instance.secretRef, field),
101
133
  signal: callOptions.signal
102
134
  }, args);
103
- attempts.push({ providerId: entry.providerId, outcome: "sent" });
104
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
105
- const sent = { ok: true, result, provider: entry.providerId, attempts };
106
- options.after?.(sent);
135
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "sent", durationMs: performance.now() - startedAt });
136
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, "success"));
137
+ const sent = {
138
+ ok: true,
139
+ result,
140
+ provider: instance.providerId,
141
+ instance: instance.instanceKey,
142
+ method: attachment.providerMethod,
143
+ selected: { providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod },
144
+ fallbackUsed: attempts.length > 1,
145
+ attempts
146
+ };
147
+ await options.after?.(sent);
107
148
  return sent;
108
149
  } catch (error) {
109
150
  if (callOptions.signal?.aborted) {
110
151
  const cancelled = {
111
152
  ok: false,
153
+ fallbackUsed: attempts.length > 0,
112
154
  attempts,
113
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
155
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
114
156
  };
115
- options.after?.(cancelled);
157
+ await options.after?.(cancelled);
116
158
  return cancelled;
117
159
  }
118
- const kind = spec.classify(error);
160
+ const kind = method.classify(error);
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ const code = error instanceof ProviderError ? error.code : typeof error?.code === "string" ? error.code : undefined;
119
163
  attempts.push({
120
- providerId: entry.providerId,
164
+ providerId: instance.providerId,
165
+ instanceKey: instance.instanceKey,
166
+ providerMethod: attachment.providerMethod,
121
167
  outcome: "failed",
122
168
  kind,
123
- error: error instanceof Error ? error.message : String(error)
169
+ durationMs: performance.now() - startedAt,
170
+ failure: { kind, ...code ? { code } : {}, message },
171
+ error: message
124
172
  });
125
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
173
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, kind));
126
174
  if (kind === "terminal") {
127
175
  const refused = {
128
176
  ok: false,
177
+ fallbackUsed: attempts.length > 1,
129
178
  attempts,
130
179
  error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
131
180
  };
132
- options.after?.(refused);
181
+ await options.after?.(refused);
133
182
  return refused;
134
183
  }
135
184
  }
136
185
  }
137
186
  const failed = {
138
187
  ok: false,
188
+ fallbackUsed: attempts.length > 1,
139
189
  attempts,
140
- error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
190
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider method is configured for "${serviceKey}.${methodKey}".` : `Every provider method for "${serviceKey}.${methodKey}" failed or was skipped.`)
141
191
  };
142
- options.after?.(failed);
192
+ await options.after?.(failed);
143
193
  return failed;
144
194
  }
145
- return { call };
195
+ function service(definition) {
196
+ return {
197
+ call(method, args, callOptions) {
198
+ return call(definition.key, method, args, callOptions);
199
+ }
200
+ };
201
+ }
202
+ return { call, service };
146
203
  }
147
- var VERSION = "0.1.8";
204
+ var VERSION = "0.1.10";
148
205
 
149
206
  // src/http.ts
150
207
  class BudgetExhausted extends ProviderError {
@@ -227,9 +284,9 @@ function createHttpClient(config) {
227
284
  budget: () => budgetState(config.budget.host)
228
285
  };
229
286
  }
230
- var http = defineProvider({
287
+ var http = defineSingleMethodProvider({
231
288
  id: "http",
232
- service: "http",
289
+ method: "request",
233
290
  label: "HTTP",
234
291
  multiInstance: true,
235
292
  credentials: {