@kindgi/capabilities
npm install @kindgi/capabilities · source
Capability declarations, a tenant-scoped model-provider registry, and a deterministic router for Kindgi. An agent declares what it needs from a model (features, context window, cost, region, latency, provider or model allow and deny lists) and what it prefers; the router picks a (provider, model) pair from the tenant's registered providers that meets every hard requirement and the tenant's policy, ranked by the preferences. The package also defines ModelProvider, the provider-neutral contract that model adapters implement.
Purpose
Section titled “Purpose”Keep agent definitions independent of any one model vendor. Agents state requirements instead of naming a model; the binding to a concrete model happens at run time, and when nothing qualifies the router returns a structured explanation of which candidate failed which requirement. Providers are registered per tenant, so routing for one tenant never sees another tenant's providers.
Exports
Section titled “Exports”- Declaring requirements
Capability—{ kind?, needs, prefer?, budget? }.kinddefaults toDEFAULT_CAPABILITY_KIND('llm-inference');BUILT_IN_CAPABILITY_KINDS/BuiltInCapabilityKindlist the well-known kinds, andCapabilityKindis an open string.Requirement— hard constraints:{ feature },{ contextWindow: { op, value } },{ costPerCall: { op, usd } }(compared against the model's prompt price per 1K tokens),{ region },{ p95LatencyMs: { op, value } },{ providers: { allow?, deny? } },{ models: { allow?, deny? } }. Operators areComparisonOp(>=,>,=,<=,<) andUpperBoundOp(<=,<) for latency.Preference—{ feature, weight };featureis aFeatureor a provider attribute such as'lower-cost'. Weights add up per candidate and may be negative.FEATURES/Feature— the closed feature set:structured-output,vision,audio-input,audio-output,tool-use,parallel-tool-use,thinking,long-context,code-execution,web-search,file-search,streaming,batch.structured-outputmeans the model can follow a JSON schema natively (ModelCallInput.structuredOutput, where its adapter maps it); an agent's typed output doesn't use that yet, and on every provider is checked by parse and repair.Budget—maxCostUsd,maxTokens,maxDurationMs.defineCapability(spec)— validates a declaration against the bundledcapability.schema.json(the same file as in@kindgi/specs; its$idis exported asCAPABILITY_SCHEMA_URI) and returnsResult<Capability, InvalidCapabilityError>. The schema acceptsneeds,preferandbudgetwith every requirement exceptmodels, so a declaration that setskindor uses amodelsrequirement is rejected here even though the type and the router accept it.
- Providers
ModelProvider—metadataplusinvoke(input: ModelCallInput): Promise<ModelCallResult>.ProviderMetadata—id,region('unspecified'if not region-scoped),models, and optionalattributes,description,capabilityKind. One provider is one connection that can expose several models.ModelInfo—name,contextWindow,features,cost(promptUsdPer1kTokens,completionUsdPer1kTokens), and optionalp95LatencyMs,maxOutputTokens,description.
- Model call shape —
ModelCallInput(model,messages, optionaltools,structuredOutput,temperature,maxOutputTokens,abortSignal),ModelMessage(rolessystem,user,assistant,tool),ModelToolCall,ModelToolDefinition,StructuredOutputRequest,ModelCallResult(message,finishReason,usage,costUsd,durationMs,provider),UsageCounters. Adapters translate these to and from their vendor SDKs. - Registry
createProviderRegistry(seed?)— in-memory registry. Returns{ registry, register }:register(tenantId, provider)validates the metadata and returns aResult(invalid-provider,duplicate-provider);registryis theProviderRegistryview, whose ownregisterthrows on the same errors.ProviderRegistry—register,get,list,has, all keyed bytenantId, plus optionalhydrate(tenantId)andinvalidate(tenantId)for implementations backed by persistent storage, andsetupFailures(tenantId): the registered providers its lasthydratecouldn't set up, each aProviderSetupFailure(providerId,message).listleaves those out.
- Routing
route(input: RouteInput)— returnsResult<RoutingDecision, CapabilityError>. It expands each provider into one candidate per model, filters bykind,needsand the tenant policy, ranks by summed preference weight, moves candidates matchingpreferredProvider/preferredModelto the front, and breaks ties on(providerId, modelName). The same inputs always produce the same decision.RouteInput—capability,providers(already scoped to one tenant, e.g.registry.list(tenantId)), and optionaltenantPolicy,preferredProvider,preferredModel, andsetupFailures(registry.setupFailures(tenantId)): when nothing routes, the error names each under the requirementprovider set up, by provider id, and its message gives the first one's why.RoutingDecision— the chosenproviderandmodel, a human-readablereason, andalternates(ProviderModelPick[]) in rank order.matchTuples(input)— the filtering step on its own: the passing candidates, unranked, orcapability-unsatisfiablewhen none pass.TenantPolicy—tenantId, provider and modelallow/denylists,regionAllow,maxCostPerCallUsd,maxTokensPerCall. An allow list (orregionAllow) that is present restricts even when empty:[]allows nothing.
- Adapter factories —
createAdapterFactoryRegistry(seed?, options?)returns anAdapterFactoryRegistry(register,get,has,list): a deployment-wide map from adapter id to the code that builds providers. AnAdapterFactoryEntryholdsadapterId,capabilityKind, afactory(AdapterFactory:(input: AdapterFactoryInput) => ModelProvider, whereAdapterFactoryInputismetadataplus optionalconfig,resolveApiKey,fetchandidentities: the runtime's own cloud identities,AdapterIdentities{ azure?: AzureTokenClient, aws?: AwsCredentialClient }, for a registration that signs in as the server), an optionalprepare(params)that streamsPrepareEvents (progress,ready,error) for downloads or warm-up, and an optionalcheckConfig(input): the adapter's static check of a provider registration (AdapterConfigCheckInput:metadata,config,hasSecretRef, andidentities, which of the runtime's identities are there; no network, no secret read), returning **AdapterConfigProblem**s (path, a JSON pointer into the registration, andmessage). Each problem'smessagenames the setting and what it takes, andfactorythrows it asadapterConfigError(adapterId, providerId, problem)words it; the runtime runs the check when a provider registers. An entry may also declare theadapter_configkeys it takes:configKeys,configKeyPrefixes(alongsideconfigKeys, the keys that start with one, such asextraBody.) andmovedConfigKeys(a key it doesn't read, mapped to where its setting goes).adapterConfigKeyProblems(entry, config)gives oneAdapterConfigProblemper key the entry doesn't take, naming keys and places, never values: the runtime refuses a new registration with any, and builds a stored one without reading them, so a factory ignores a key it doesn't read. An entry withoutconfigKeystakes any key. A registry made withoptions.identitieshands them to every factory and tells every check which are there, so a registration needing an identity the runtime lacks is refused when it registers. Registering the same id twice throws. - Errors —
CapabilityError, a union ofInvalidCapabilityError,InvalidProviderError,DuplicateProviderError,CapabilityUnsatisfiableErrorandBudgetExceededError.CapabilityUnsatisfiableError.reasonslists, for each requirement that rejected at least one candidate, the candidates that satisfied it and a structuredRejectionReasonfor each that did not (missing-feature,context-window-mismatch,region-mismatch,tenant-denied,capability-kind-mismatch,provider-setup-failed, …).
Example
Section titled “Example”import { createProviderRegistry, defineCapability, route } from '@kindgi/capabilities';import type { ModelProvider } from '@kindgi/capabilities';import type { TenantId } from '@kindgi/types';
// One connection to a model vendor, exposing two models.const acmeLlm: ModelProvider = { metadata: { id: 'acme-llm', region: 'eu-west-1', attributes: ['lower-cost'], models: [ { name: 'acme-large', contextWindow: 200_000, features: ['tool-use', 'structured-output', 'streaming'], cost: { promptUsdPer1kTokens: 0.003, completionUsdPer1kTokens: 0.015 }, p95LatencyMs: 4_000, }, { name: 'acme-small', contextWindow: 32_000, features: ['tool-use'], cost: { promptUsdPer1kTokens: 0.0002, completionUsdPer1kTokens: 0.0008 }, }, ], }, invoke: (input) => callAcmeApi(input), // your vendor SDK call};
const tenantId = process.env.TENANT_ID as TenantId;const { registry, register } = createProviderRegistry();const registered = register(tenantId, acmeLlm);if (registered.kind === 'err') throw new Error(registered.error.message);
const capability = defineCapability({ needs: [{ feature: 'tool-use' }, { contextWindow: { op: '>=', value: 100_000 } }], prefer: [{ feature: 'lower-cost', weight: 1 }],});if (capability.kind === 'err') throw new Error(capability.error.message);
const decision = route({ capability: capability.value, providers: registry.list(tenantId), tenantPolicy: { tenantId, regionAllow: ['eu-west-1'] },});if (decision.kind === 'err') { if (decision.error.code === 'capability-unsatisfiable') { for (const r of decision.error.reasons) { console.error(r.requirement, r.rejectingProviders.map((p) => `${p.id}: ${p.reason.code}`)); } } process.exit(1);}
// acme-small fails the context-window requirement, so this is acme-llm / acme-large.const { provider, model } = decision.value;const reply = await provider.invoke({ model: model.name, messages: [{ role: 'user', content: 'Summarize clause 4.2 in one sentence.' }],});console.log(reply.message.content, reply.costUsd);Non-goals
Section titled “Non-goals”- No vendor adapters. Concrete
ModelProviderimplementations live in adapter packages such as those underpackages/adapters. - No cost metering.
BudgetandBudgetExceededErrordescribe limits; nothing in this package tracks spend or enforces them. - No retries or failover.
routereturns rankedalternates; retrying with the next candidate is up to the caller. - No persistence. Both registries are in-memory. Implementations backed by storage plug in through
ProviderRegistry.hydrateandinvalidate.
Related
Section titled “Related”@kindgi/agents— routes an agent's capability declaration withrouteat the start of every turn.@kindgi/specs—capability.schema.json, the wire schemadefineCapabilityvalidates against.@kindgi/dev-echo-provider— aModelProviderfor local development.@kindgi/types—TenantIdandResult.