@kindgi/capabilities 0.0.0-bootstrap.0 → 0.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.
package/src/types.ts ADDED
@@ -0,0 +1,533 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { TenantId } from '@kindgi/types';
5
+
6
+ /**
7
+ * The closed set of feature flags an agent can require. Matches
8
+ * `@kindgi/specs/capability.schema.json` FeatureRequirement enum. Providers self-
9
+ * report which of these they satisfy.
10
+ */
11
+ export const FEATURES = [
12
+ 'structured-output',
13
+ 'vision',
14
+ 'audio-input',
15
+ 'audio-output',
16
+ 'tool-use',
17
+ 'parallel-tool-use',
18
+ 'thinking',
19
+ 'long-context',
20
+ 'code-execution',
21
+ 'web-search',
22
+ 'file-search',
23
+ 'streaming',
24
+ 'batch',
25
+ ] as const;
26
+
27
+ export type Feature = (typeof FEATURES)[number];
28
+
29
+ /** Comparison operator used by numeric requirements (context, cost, latency). */
30
+ export type ComparisonOp = '>=' | '>' | '=' | '<=' | '<';
31
+
32
+ /** Numeric-comparison-only operators (latency can only bound from above). */
33
+ export type UpperBoundOp = '<=' | '<';
34
+
35
+ /**
36
+ * One member of the Requirement union — hard constraints that MUST hold.
37
+ *
38
+ * `providers` filters on `ProviderMetadata.id` (vendor connection);
39
+ * `models` filters on `ModelInfo.name` (specific model within a
40
+ * connection). Both may appear in the same capability declaration —
41
+ * "any provider EXCEPT anthropic, and any model EXCEPT haiku."
42
+ */
43
+ export type Requirement =
44
+ | { readonly feature: Feature }
45
+ | { readonly contextWindow: { readonly op: ComparisonOp; readonly value: number } }
46
+ | { readonly costPerCall: { readonly op: ComparisonOp; readonly usd: number } }
47
+ | { readonly region: string }
48
+ | { readonly p95LatencyMs: { readonly op: UpperBoundOp; readonly value: number } }
49
+ | {
50
+ readonly providers: { readonly allow?: readonly string[]; readonly deny?: readonly string[] };
51
+ }
52
+ | {
53
+ readonly models: { readonly allow?: readonly string[]; readonly deny?: readonly string[] };
54
+ };
55
+
56
+ /**
57
+ * A soft preference. Router filters by `needs` first, then ranks the
58
+ * survivors by summed weights of matched preferences.
59
+ *
60
+ * `feature` may be a canonical Feature name (e.g. `'thinking'`) or a
61
+ * soft attribute the provider self-reports (`'lower-cost'`,
62
+ * `'lower-latency'`, `'higher-accuracy'`, `'local'`, etc.). Weight can be
63
+ * negative (deprioritize).
64
+ */
65
+ export interface Preference {
66
+ readonly feature: string;
67
+ readonly weight: number;
68
+ }
69
+
70
+ /**
71
+ * Per-invocation budget. Declarative: carried with the declaration but not
72
+ * enforced by the runtime (per-turn spend is capped by the agent's
73
+ * `budget.maxCostUsd`).
74
+ */
75
+ export interface Budget {
76
+ readonly maxCostUsd?: number;
77
+ readonly maxTokens?: number;
78
+ readonly maxDurationMs?: number;
79
+ }
80
+
81
+ /**
82
+ * A capability declaration. Every agent (or every model-invoking node)
83
+ * declares one; the router uses it to pick a provider.
84
+ *
85
+ * `kind` discriminates the resource type being requested. An absent
86
+ * value is equivalent to `'llm-inference'`. Non-LLM kinds (`embedding`, `gpu-compute`,
87
+ * `sandbox-exec`, `browser-session`) are declared here as strings so
88
+ * adapter packages can register providers of that kind without a
89
+ * schema break. The router filters providers by
90
+ * `metadata.capabilityKind === capability.kind`.
91
+ */
92
+ export interface Capability {
93
+ readonly kind?: CapabilityKind;
94
+ readonly needs: readonly Requirement[];
95
+ readonly prefer?: readonly Preference[];
96
+ readonly budget?: Budget;
97
+ }
98
+
99
+ /**
100
+ * Open string discriminating capability kinds. Well-known values
101
+ * enumerated in `BUILT_IN_CAPABILITY_KINDS`. Adapters register
102
+ * providers of other kinds by declaring their own strings; the router
103
+ * matches strictly on equality.
104
+ */
105
+ export type CapabilityKind = string;
106
+
107
+ /** Well-known capability kinds — for enums, schema validation, docs. */
108
+ export const BUILT_IN_CAPABILITY_KINDS = [
109
+ 'llm-inference',
110
+ 'embedding',
111
+ 'gpu-compute',
112
+ 'sandbox-exec',
113
+ 'browser-session',
114
+ ] as const;
115
+ export type BuiltInCapabilityKind = (typeof BUILT_IN_CAPABILITY_KINDS)[number];
116
+
117
+ /** Default kind used when Capability.kind is omitted. */
118
+ export const DEFAULT_CAPABILITY_KIND: CapabilityKind = 'llm-inference';
119
+
120
+ /**
121
+ * Structured rejection reason surfaced when a provider fails a
122
+ * requirement. Each variant carries `code` (for programmatic
123
+ * matching) and `message` (for logs). Downstream callers can
124
+ * `switch (r.code)` to route on specific failure modes without
125
+ * string-parsing.
126
+ *
127
+ * The router's requirement checks return this discriminated union
128
+ * (rather than a bare string) so consumers of
129
+ * `CapabilityUnsatisfiableError.reasons[].rejectingProviders[].reason`
130
+ * can inspect programmatically.
131
+ */
132
+ export type RejectionReason =
133
+ | { readonly code: 'missing-feature'; readonly message: string; readonly feature: string }
134
+ | {
135
+ readonly code: 'context-window-mismatch';
136
+ readonly message: string;
137
+ readonly observed: number;
138
+ readonly op: ComparisonOp;
139
+ readonly wanted: number;
140
+ }
141
+ | {
142
+ readonly code: 'cost-exceeds-cap';
143
+ readonly message: string;
144
+ readonly observedUsdPer1k: number;
145
+ readonly op: ComparisonOp;
146
+ readonly wantedUsd: number;
147
+ }
148
+ | {
149
+ readonly code: 'region-mismatch';
150
+ readonly message: string;
151
+ readonly observed: string;
152
+ readonly wanted: string;
153
+ }
154
+ | { readonly code: 'p95-latency-unknown'; readonly message: string }
155
+ | {
156
+ readonly code: 'p95-latency-exceeds';
157
+ readonly message: string;
158
+ readonly observedMs: number;
159
+ readonly op: UpperBoundOp;
160
+ readonly wantedMs: number;
161
+ }
162
+ | {
163
+ readonly code: 'provider-on-denylist';
164
+ readonly message: string;
165
+ readonly providerId: string;
166
+ }
167
+ | {
168
+ readonly code: 'provider-not-in-allowlist';
169
+ readonly message: string;
170
+ readonly providerId: string;
171
+ readonly allow: readonly string[];
172
+ }
173
+ | {
174
+ readonly code: 'model-on-denylist';
175
+ readonly message: string;
176
+ readonly modelName: string;
177
+ }
178
+ | {
179
+ readonly code: 'model-not-in-allowlist';
180
+ readonly message: string;
181
+ readonly modelName: string;
182
+ readonly allow: readonly string[];
183
+ }
184
+ | {
185
+ readonly code: 'tenant-denied';
186
+ readonly message: string;
187
+ readonly providerId: string;
188
+ }
189
+ | {
190
+ readonly code: 'tenant-not-allowed';
191
+ readonly message: string;
192
+ readonly providerId: string;
193
+ }
194
+ | {
195
+ readonly code: 'tenant-model-denied';
196
+ readonly message: string;
197
+ readonly modelName: string;
198
+ }
199
+ | {
200
+ readonly code: 'tenant-model-not-allowed';
201
+ readonly message: string;
202
+ readonly modelName: string;
203
+ }
204
+ | {
205
+ readonly code: 'tenant-region-not-allowed';
206
+ readonly message: string;
207
+ readonly observed: string;
208
+ readonly allow: readonly string[];
209
+ }
210
+ | {
211
+ readonly code: 'tenant-cost-cap-exceeded';
212
+ readonly message: string;
213
+ readonly observedUsd: number;
214
+ readonly capUsd: number;
215
+ }
216
+ | {
217
+ readonly code: 'capability-kind-mismatch';
218
+ readonly message: string;
219
+ readonly observed: CapabilityKind;
220
+ readonly wanted: CapabilityKind;
221
+ }
222
+ | { readonly code: 'unknown-requirement'; readonly message: string };
223
+
224
+ /**
225
+ * Per-model metadata. A provider row exposes one or more models (e.g.
226
+ * Anthropic exposing Sonnet + Opus + Haiku under one API key); each
227
+ * `ModelInfo` describes one entry the router can select. The router
228
+ * picks a `(provider, model)` tuple per invocation, and the caller
229
+ * threads `ModelCallInput.model = <ModelInfo.name>` so the adapter
230
+ * knows which model to invoke.
231
+ *
232
+ * Adapters may widen `cost` via intersection types when they need
233
+ * vendor-specific fields (e.g. Anthropic prompt-cache multipliers) —
234
+ * the framework declares the mandatory shape, adapters extend.
235
+ */
236
+ export interface ModelInfo {
237
+ /** Vendor-facing model id passed to the SDK (e.g. `claude-sonnet-4-6`). */
238
+ readonly name: string;
239
+ /** Context window in tokens. */
240
+ readonly contextWindow: number;
241
+ /** Features this model supports (tool-use, thinking, structured-output, ...). */
242
+ readonly features: readonly Feature[];
243
+ /**
244
+ * Cost per 1K tokens. Prompt + completion billed separately; both
245
+ * required. Denominated in USD.
246
+ */
247
+ readonly cost: {
248
+ readonly promptUsdPer1kTokens: number;
249
+ readonly completionUsdPer1kTokens: number;
250
+ };
251
+ /** Best-effort p95 latency estimate in ms — varies by model. */
252
+ readonly p95LatencyMs?: number;
253
+ /**
254
+ * Fallback cap on output tokens. Adapters that require max_tokens on
255
+ * every request (e.g. Anthropic) use this when
256
+ * `ModelCallInput.maxOutputTokens` is unset.
257
+ */
258
+ readonly maxOutputTokens?: number;
259
+ /** Short per-model description surfaced in logs. */
260
+ readonly description?: string;
261
+ }
262
+
263
+ /**
264
+ * What a `ModelProvider` reports about itself so the router can match
265
+ * capabilities against it without invoking. Static metadata — no side
266
+ * effects, no network. Every field is provider-declared and the router
267
+ * trusts it; misrepresentation is a provider bug.
268
+ *
269
+ * Provider-level fields (`id`, `region`, `attributes`) describe the
270
+ * connection; per-model fields (context window, cost, features) live
271
+ * under `models[]` so one connection can expose multiple models. The
272
+ * router picks a `(provider, model)` tuple per invocation.
273
+ */
274
+ export interface ProviderMetadata {
275
+ readonly id: string;
276
+ /**
277
+ * Region the connection routes to. All models on this provider share
278
+ * the region (Vertex-AI-Anthropic vs Bedrock-Anthropic vs
279
+ * Anthropic-direct are separate provider rows, one per region).
280
+ * `'unspecified'` if not region-scoped.
281
+ */
282
+ readonly region: string;
283
+ /**
284
+ * Models this connection exposes. Non-empty — a provider with zero
285
+ * models is unusable and rejected at registration. `models[i].name`
286
+ * must be unique within the list.
287
+ */
288
+ readonly models: readonly ModelInfo[];
289
+ /**
290
+ * Soft attributes the provider self-reports for preference-ranking.
291
+ * Examples: `'local'`, `'lower-cost'`, `'higher-accuracy'`. Opaque to
292
+ * the router — matched by string equality against `Preference.feature`.
293
+ */
294
+ readonly attributes?: readonly string[];
295
+ /** Short human description of the connection surfaced in logs + errors. */
296
+ readonly description?: string;
297
+ /**
298
+ * Kind of resource this provider fulfils. Optional — an absent value
299
+ * defaults to `'llm-inference'`. Adapters for non-LLM kinds
300
+ * (embedding, GPU compute, sandboxes, ...) set this explicitly. The router filters providers by
301
+ * `metadata.capabilityKind === capability.kind`.
302
+ */
303
+ readonly capabilityKind?: CapabilityKind;
304
+ /**
305
+ * A fallback serves a capability only when no other provider
306
+ * satisfies it — e.g. `kindgi dev`'s scripted `dev-echo`, which keeps
307
+ * agent turns runnable until a real model is registered. A turn routed
308
+ * to one says so (`RoutingDecision.fallback`).
309
+ */
310
+ readonly fallback?: boolean;
311
+ }
312
+
313
+ /**
314
+ * A single message in a model call. Standardised wire shape — provider
315
+ * adapters translate to/from their vendor SDK format.
316
+ */
317
+ export interface ModelMessage {
318
+ readonly role: 'system' | 'user' | 'assistant' | 'tool';
319
+ /** Text content. Tool-use payloads carry their own fields; text may be empty. */
320
+ readonly content: string;
321
+ /** For role='tool': the tool call id being responded to. */
322
+ readonly toolCallId?: string;
323
+ /** For role='assistant': tool calls the model wants executed. */
324
+ readonly toolCalls?: readonly ModelToolCall[];
325
+ }
326
+
327
+ export interface ModelToolCall {
328
+ readonly id: string;
329
+ readonly name: string;
330
+ /** JSON arguments as parsed object. */
331
+ readonly arguments: Readonly<Record<string, unknown>>;
332
+ /**
333
+ * An opaque token the provider attached to this call and needs back
334
+ * when the conversation continues — Gemini's thought signature, for
335
+ * example. Callers keep it with the call, unchanged; only the adapter
336
+ * that produced it reads it.
337
+ */
338
+ readonly signature?: string;
339
+ }
340
+
341
+ /** Tool definition passed to a model call — JSON-Schema-shaped, provider-neutral. */
342
+ export interface ModelToolDefinition {
343
+ readonly name: string;
344
+ readonly description: string;
345
+ readonly inputSchema: Readonly<Record<string, unknown>>;
346
+ }
347
+
348
+ /**
349
+ * Structured-output request — the model returns JSON matching this
350
+ * schema. Shape aligns with OpenAI's `response_format.json_schema`.
351
+ *
352
+ * Adapter coverage:
353
+ * - `@kindgi/adapter-model-openai-compat` — translates to
354
+ * `response_format: { type: "json_schema", json_schema: { name,
355
+ * schema, strict: true } }`.
356
+ * - `@kindgi/adapter-model-anthropic` — ignores it (see that
357
+ * package's README).
358
+ */
359
+ export interface StructuredOutputRequest {
360
+ readonly name: string;
361
+ readonly schema: Readonly<Record<string, unknown>>;
362
+ }
363
+
364
+ /**
365
+ * The input to a model invocation. All fields provider-agnostic; each
366
+ * adapter translates to its vendor SDK format.
367
+ */
368
+ export interface ModelCallInput {
369
+ /**
370
+ * Which model to invoke. Must match one of the receiving provider's
371
+ * `metadata.models[i].name`. Set by the caller from
372
+ * `RoutingDecision.model.name` (typical path) or hand-picked when
373
+ * the caller has a specific pin in mind. Adapters that don't
374
+ * recognize the name reject with an `unknown-model` error.
375
+ */
376
+ readonly model: string;
377
+ readonly messages: readonly ModelMessage[];
378
+ readonly tools?: readonly ModelToolDefinition[];
379
+ readonly structuredOutput?: StructuredOutputRequest;
380
+ /** Sampling controls — providers translate to their equivalent. */
381
+ readonly temperature?: number;
382
+ readonly maxOutputTokens?: number;
383
+ /** Cooperative cancellation. Handlers should observe. */
384
+ readonly abortSignal?: AbortSignal;
385
+ }
386
+
387
+ /** Usage counters — used by the cost meter. */
388
+ export interface UsageCounters {
389
+ readonly promptTokens: number;
390
+ readonly completionTokens: number;
391
+ /** Some providers report cached tokens separately. Optional. */
392
+ readonly cachedTokens?: number;
393
+ }
394
+
395
+ /**
396
+ * The result of a model invocation. `finishReason` matches OpenAI-style
397
+ * conventions; adapters normalize to this set.
398
+ */
399
+ export interface ModelCallResult {
400
+ readonly message: ModelMessage;
401
+ readonly finishReason: 'stop' | 'length' | 'tool-use' | 'content-filter' | 'error';
402
+ readonly usage: UsageCounters;
403
+ /**
404
+ * Denominated in USD. Provider computes this from the actual token
405
+ * usage against its cost table. Cost meter uses it directly rather
406
+ * than re-computing (in case pricing changes between adapter
407
+ * versions).
408
+ */
409
+ readonly costUsd: number;
410
+ readonly durationMs: number;
411
+ /** Provider + model actually invoked (in case the router picked a variant). */
412
+ readonly provider: { readonly id: string; readonly model: string };
413
+ }
414
+
415
+ /**
416
+ * A registered provider — metadata + invoke function. Registered once at
417
+ * pack init; invoked many times by the router.
418
+ */
419
+ export interface ModelProvider {
420
+ readonly metadata: ProviderMetadata;
421
+ invoke(input: ModelCallInput): Promise<ModelCallResult>;
422
+ }
423
+
424
+ /**
425
+ * Registry the router consults. Tenant-scoped: each tenant owns its
426
+ * own set of registered providers, and the router only sees a
427
+ * tenant's slice.
428
+ *
429
+ * Isolation model:
430
+ * - Isolation across tenants is enforced here (not by after-the-fact
431
+ * policy filtering).
432
+ * - A deployment either (a) runs one Kindgi instance per tenant
433
+ * with its own set of registered providers, or (b) runs a shared
434
+ * instance where the framework registers per-tenant provider
435
+ * entries — same adapter binary, different keys per tenant (each
436
+ * provider closes over that tenant's secrets).
437
+ * - `TenantPolicy` still narrows the visible set at routing time
438
+ * (allow/deny lists, cost caps, region gates) — that stays
439
+ * orthogonal to tenant isolation.
440
+ *
441
+ * This aligns with the storage-side `ProviderRegistryBinding` (in
442
+ * `@kindgi/api`), whose methods all take `tenantId`. A registry backed
443
+ * by that storage reads persisted provider metadata and instantiates
444
+ * providers through an `AdapterFactoryRegistry`.
445
+ */
446
+ export interface ProviderRegistry {
447
+ register(tenantId: TenantId, provider: ModelProvider): void;
448
+ get(tenantId: TenantId, id: string): ModelProvider | undefined;
449
+ list(tenantId: TenantId): readonly ModelProvider[];
450
+ has(tenantId: TenantId, id: string): boolean;
451
+ /**
452
+ * Optional async warm-up. Storage-backed implementations
453
+ * hydrate their per-tenant instance cache from the persistent
454
+ * `ProviderRegistryBinding` on the first call for a given tenant;
455
+ * the agent runtime awaits this before the sync `list(tenantId)` call
456
+ * so the router sees the full set. In-memory implementations that
457
+ * hold their state eagerly leave this undefined, and callers skip it.
458
+ *
459
+ * Idempotent: hydrating an already-hydrated tenant returns
460
+ * immediately.
461
+ */
462
+ hydrate?(tenantId: TenantId): Promise<void>;
463
+ /**
464
+ * Optional cache invalidation. Storage-backed implementations call
465
+ * this from the provider write path (register, unregister) so the
466
+ * next `hydrate(tenantId)` re-reads from the persistent store.
467
+ * Without it, a provider registered after boot is persisted but the
468
+ * cache keeps serving the providers loaded at boot. In-memory
469
+ * implementations leave it undefined — no cache to invalidate.
470
+ */
471
+ invalidate?(tenantId: TenantId): void;
472
+ }
473
+
474
+ /**
475
+ * Per-tenant policy the router honors on top of the capability
476
+ * declaration. Every field optional; empty policy = "no additional
477
+ * constraints."
478
+ *
479
+ * `providers.allow` is a positive list of provider ids; if present,
480
+ * only listed providers are eligible — an empty list allows none.
481
+ * `providers.deny` excludes. `models.allow` / `models.deny` do the same
482
+ * at the model level (e.g. "tenant may use anthropic but not opus").
483
+ * Deny wins over allow where the two overlap at either level.
484
+ *
485
+ * `regionAllow` narrows to specific provider regions; an empty list
486
+ * allows none.
487
+ * `maxCostPerCallUsd` caps per-invocation cost using the picked
488
+ * model's `promptUsdPer1kTokens` as the cheap proxy.
489
+ * `maxTokensPerCall` caps the picked model's context window.
490
+ */
491
+ export interface TenantPolicy {
492
+ readonly tenantId: TenantId;
493
+ readonly providers?: {
494
+ readonly allow?: readonly string[];
495
+ readonly deny?: readonly string[];
496
+ };
497
+ readonly models?: {
498
+ readonly allow?: readonly string[];
499
+ readonly deny?: readonly string[];
500
+ };
501
+ readonly regionAllow?: readonly string[];
502
+ readonly maxCostPerCallUsd?: number;
503
+ readonly maxTokensPerCall?: number;
504
+ }
505
+
506
+ /** A `(provider, model)` tuple as picked by the router. */
507
+ export interface ProviderModelPick {
508
+ readonly provider: ModelProvider;
509
+ readonly model: ModelInfo;
510
+ }
511
+
512
+ /**
513
+ * The router's decision output. Returns a `(provider, model)` tuple —
514
+ * the picked connection AND the picked model within it. Callers thread
515
+ * `ModelCallInput.model = decision.model.name` when invoking
516
+ * `provider.invoke`.
517
+ */
518
+ export interface RoutingDecision {
519
+ readonly provider: ModelProvider;
520
+ readonly model: ModelInfo;
521
+ readonly reason: string;
522
+ /**
523
+ * Alternate tuples in ranked order. Useful for failover — if the top
524
+ * pick throws or hits a rate limit, the caller can retry with the
525
+ * next.
526
+ */
527
+ readonly alternates: readonly ProviderModelPick[];
528
+ /**
529
+ * `true` when only fallback providers (`ProviderMetadata.fallback`)
530
+ * satisfy the capability, so the pick is one of them.
531
+ */
532
+ readonly fallback: boolean;
533
+ }