Provider plugins¶
korvid routes every model reference through LiteLLM's bundled catalog, so over
2,000 models work out of the box. You almost certainly do not need a
plugin. Write one only when your backend's wire protocol is not
OpenAI-compatible, or when your auth is genuinely non-standard — an
OpenAI-compatible backend at any URL needs a profile endpoint, and unusual
auth alone needs a korvid.credential chain (below).
Security warning: these are trusted, in-process Python loaded into the korvid process. Selected-only loading avoids importing unused plugins, but it is not a sandbox — install only what you trust. A flow's builder receives only the connection profile, never conversation data or an injected credential source, and what it returns is called with the same sanitized canonical
messages/toolspayloadOutboundPolicybuilds for every other transport (seedocs/threat-model.md). Once yourcomplete()has that payload it may mutate, retain, log or transmit it anywhere. SeeSECURITY.mdto report a vulnerability.
Special flows (the current extension point)¶
The korvid.provider entry-point group loads SpecialFlow declarations.
A SpecialFlow is a frozen dataclass — data, not a base class to subclass —
claiming exactly one reference prefix (or one named boolean option on a prefix
it otherwise shares) and supplying only what the standard transport
structurally cannot.
An entry point may resolve to a SpecialFlow instance, or to a module exposing
a zero-argument korvid_special_flows() returning SpecialFlow objects, of
which korvid takes the first. The entry-point name is the claimed prefix; a
flow whose own prefix normalizes differently is refused.
# my_pkg/flow.py
from korvid.agent.model_profiles import (
AuthMethodDescriptor,
EndpointRequirement,
ModelConnectionConfig,
SpecialFlow,
)
from korvid.agent.provider import LLMProvider
def build_provider(profile: ModelConnectionConfig) -> LLMProvider | None: ...
def korvid_special_flows() -> tuple[SpecialFlow, ...]:
return (
SpecialFlow(
prefix="my-backend",
display_name="My Backend",
auth_methods=(AuthMethodDescriptor(id="none", display_name="No key"),),
endpoint=EndpointRequirement.OPTIONAL,
build_provider=build_provider,
),
)
Every field:
| Field | Meaning |
|---|---|
prefix |
The claimed prefix; must match [a-z0-9][a-z0-9_-]*. |
display_name |
What the wizard shows. |
auth_methods |
Methods offered for this prefix. A non-empty tuple replaces the catalog's generic list for every reference under the prefix, including ones this flow does not serve — korvid's Ollama flow declares () for that reason. |
option_fields |
Declarative SetupField prompts (TEXT, SECRET_REF, BOOLEAN, INTEGER, CHOICE) — data, never executable UI. |
endpoint |
REQUIRED, OPTIONAL (default) or UNSUPPORTED: whether the wizard must, may, or must not ask for one. |
claims_option |
A boolean option activating the flow on a prefix it shares; None means it owns the prefix outright. |
build_provider |
(ModelConnectionConfig) -> LLMProvider \| None. None for the field itself means declaration only: the reference is still kept from the standard transport, but nothing can be built, so the factory refuses instead of routing. |
begin_auth |
async (profile) -> DeviceLoginPrompt \| None, starting the flow's sign-in. None means the wizard skips the stage rather than inventing one. |
finish_auth |
async (profile) -> str \| None, returning the credential key the profile will name — never the secret. |
korvid.providers.flow_ollama_thinking ships the option form,
korvid.providers.flow_copilot the prefix form.
Prefix claiming, normalization, and the exclusive names¶
A prefix is normalized by stripping surrounding whitespace, lowercasing and
folding _ to - — and nothing else. A dot is not folded: company.llm
fails the [a-z0-9][a-z0-9_-]* pattern and is rejected outright, while
Company_LLM and company-llm normalize alike and collide, so the second
declaration is rejected rather than silently shadowing the first.
Three sets are enforced whatever the transport publishes, and they are not interchangeable:
- Never routable — the retired aliases
openai-compat,vllm,github,claude, plus the device-login prefixesgithub-copilotandchatgpt. These stay claimed even with no flow to serve them — a retired alias an operator still reads as korvid's own must stay unroutable, and a device-login prefix starts an interactive sign-in inside the SDK's own routing call (see the threat model). - korvid's own routes —
openai,azure,anthropicandollama. Fully routable (openai/gpt-4ostays dispatchable), but unregistrable by a third party. Held statically so a vendor release dropping a row frommodels_by_provider()cannot hand an operator's prefix to whoever registered the entry point. korvid's own distribution may still declare them. - LiteLLM's dynamic catalog — every prefix
models_by_provider()publishes at startup. A third party cannot shadow a name the SDK ships natively; korvid's own distribution is still exempt.
A rejected entry-point name is reported at startup, not fatal: the registry collects the reason for the setup UI's banner and korvid starts normally.
Selected-only loading, and what a failure costs¶
Construction reads entry-point names only and imports nothing. A name is loaded the first time a reference resolving to it is claimed, and only that one.
A load that raises is a logged warning and a setup-banner line, never a startup exception; the result is memoized so a repeated claim neither reloads nor re-reports. A prefix the standard transport also publishes falls back to being routed; one nothing else can serve stays claimed and refused. An option-only flow shares its prefix, so ordinary references under it stay routable either way.
Option claiming¶
A flow receives its profile's options, and claims_option activates it only
when that key is exactly True — not merely truthy, so a quoted "true"
cannot silently switch transports. The secret policy below applies first.
Credential chains for provider-default¶
A backend whose transport korvid already speaks but whose credential it
does not may declare a chain instead of a flow: a prefix, a display name, a
resolve callable returning transport call parameters carrying a refreshing
credential (never a resolved secret), and an optional requires_extra used
only in refusal messages.
# acme_korvid_provider/credentials.py
from korvid.providers.provider_default import (
CredentialUnavailable,
ProviderDefaultCredential,
ResolvedCredential,
)
def _resolve() -> ResolvedCredential:
if not _signed_in():
raise CredentialUnavailable("run `acme login` first")
return ResolvedCredential(parameters={"azure_ad_token_provider": _token})
def korvid_provider_default_credentials() -> tuple[ProviderDefaultCredential, ...]:
return (ProviderDefaultCredential("company-llm", "Company SSO", _resolve),)
korvid consults a chain only when the profile says auth.method: provider-default,
so an installed package cannot change authentication behind the operator's back —
and never on a name korvid ships. When resolve raises CredentialUnavailable,
korvid refuses the profile at build time, quoting that message. korvid's own
Entra ID chain for azure is declared this way.
Migrating the removed construction API¶
The former ProviderPlugin construction API and its registry have been removed;
there are no import compatibility shims. korvid loads korvid.provider entry
points as SpecialFlow declarations. Publish a flow whose prefix is your
entry-point name, move the former factory body into build_provider(profile),
and point the entry point at its module. The returned object still implements
LLMProvider; authentication and profile options use the current contracts below.
Configuring a third-party flow¶
A SpecialFlow.build_provider function returns an LLMProvider satisfying the
event and capability contracts below. Publish the flow with a prefix matching
its entry-point name.
Third-party prefixes are configured by hand; the :ai wizard does not discover
them:
agent:
active: company
profiles:
company:
model: company-llm/cluster-brain
endpoint: https://llm.example.internal
auth: {method: environment, key: COMPANY_LLM_TOKEN}
options: {tenant: platform}
Current extension contracts¶
Declaration types come from korvid.agent.model_profiles (SpecialFlow,
AuthMethodDescriptor, SetupField, SetupFieldKind, EndpointRequirement,
ModelConnectionConfig, DeviceLoginPrompt); the provider contract from
korvid.agent.credentials, korvid.agent.model_policy and
korvid.agent.provider; and a credential chain's from
korvid.providers.provider_default. Import these contracts from their defining
modules, not from package-level re-exports.
LLMProvider has no name property. It has descriptor and capabilities
properties, plus
complete(messages, tools, *, stream=True) -> AsyncIterator[Mapping] and
async aclose(). The flow factory catches builder failures, but does not wrap
the returned provider in a descriptor/capabilities validator. Adapters must
validate these properties themselves. ModelRouter reads them when resolving
a policy and enforces the routing rules below, not a complete property schema.
ModelCapabilities carries context_window_tokens, supports_tools,
supports_parallel_tools, supports_reasoning, recommended_tier and a
provenance mapping, each fact independently unknown; reporting nothing is
valid — ModelCapabilities.unknown() falls back to korvid's catalog, then to
low. supports_tools=False is a hard stop (korvid refuses to start the
agent rather than route a model that cannot call tools),
supports_parallel_tools is honored only on high, recommended_tier loses
to an explicit agent.model_tier, and provenance must map a known fact name
to a CapabilitySource.
complete() must return an async iterator directly; an async generator is
the usual implementation (async def with yield), while a coroutine that
must first be awaited is not.
prepare_messages() runs before outbound validation, including for a provider
returned by a flow. It may add dialect fields but must preserve the count,
order, roles and content of messages so redaction records still refer to the
right positions. Changes made later inside complete() are outside the
inspected request snapshot.
The provider a flow returns¶
build_provider returns an LLMProvider built from the profile it is handed:
profile.endpoint, profile.model and profile.options. There is no
base_url key — a profile names its host in endpoint — and one that does not
carry what the backend needs is refused by returning None or raising.
The factory reports a generic construction failure rather than forwarding the
builder's exception text, which could contain a credential. A custom flow is
responsible for constructing any credential source its provider needs.
import httpx
from korvid.agent.credentials import CredentialSource
from korvid.agent.model_policy import CapabilitySource, ModelCapabilities, ModelDescriptor
from korvid.agent.provider import LLMProvider
class CompanyProvider(LLMProvider):
def __init__(self, *, endpoint: str, model: str, creds: CredentialSource | None) -> None:
self._endpoint, self._model, self._creds = endpoint.rstrip("/"), model, creds
self._client = httpx.AsyncClient(timeout=30.0)
@property
def descriptor(self) -> ModelDescriptor:
return ModelDescriptor("company-llm", self._model)
@property
def capabilities(self) -> ModelCapabilities: # omitted facts stay unknown
return ModelCapabilities(
supports_tools=True,
provenance={"supports_tools": CapabilitySource.PROVIDER},
)
async def complete( # an async generator, never a coroutine
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]],
*, stream: bool = True,
) -> AsyncIterator[dict[str, Any]]:
reply = await self._client.post(
f"{self._endpoint}/chat",
json={"model": self._model, "messages": messages, "tools": tools},
headers=await self._creds.headers() if self._creds else {},
)
reply.raise_for_status()
yield {"type": "text_delta", "text": reply.json()["text"]}
yield {"type": "done"}
async def aclose(self) -> None:
try:
await self._client.aclose()
finally:
if self._creds is not None:
await self._creds.aclose()
Event contract and transport limits¶
The flow returns its provider directly, without the removed plugin validator. Its adapter must translate its wire protocol into these events:
| Event | Shape |
|---|---|
text_delta |
{"type": "text_delta", "text": str} |
tool_call |
{"type": "tool_call", "id": str, "name": str, "arguments": str} |
usage |
{"type": "usage", "input_tokens": int, "output_tokens": int} |
done |
{"type": "done"} |
Tool IDs and names must be non-empty, IDs must not repeat within retained history, and arguments must encode a JSON object. The engine discards unusable IDs/names and excess calls, refuses invalid arguments before dispatch, and applies the resolved policy's response budget. It is not a general-purpose validator for every field an adapter might yield.
The response budget maintains two independent counters:
- Characters: aggregate text/reasoning and tool-call ID/name/argument characters.
- Events: the number of completion events.
Each counter is compared separately with the resolved policy's
max_history_chars: 24,000 for the shipped low tier and 120,000 for
high. They are not added together. Exceeding either limit stops the response
before dispatch with ProviderResponseLimitError. This is
not a per-field UTF-8 byte limit. There is no shared 256-character ID/name
gate or 65,536-byte text-event gate; those belonged to the retired, unwired
plugin validator. Adapters must bound fields and buffers before yielding them,
not rely on the engine to reclaim an allocation already made by their transport.
Custom adapters must validate usage before yielding it: use non-bool
integers between 0 and 1,000,000,000 inclusive for each token count.
The ceiling is available as korvid.agent.diagnostics.MAX_USAGE_TOKENS.
Native diagnostic metrics enforce it, but general engine usage accounting only
coerces counts to non-negative integers: it does not enforce that ceiling or
reject bools. Reject malformed/out-of-range usage in the adapter rather than
reporting invented counts or relying on that coercion.
Built-in transports enforce additional limits while assembling a response:
one call's accumulated arguments stop at 65,536 characters, one response
may open at most 64 calls, and retained reasoning is capped at 65,536
characters. These constants and bounded-append helpers live in
korvid.agent.provider. A custom adapter must bound its own transport buffers
and reject a stream whose underlying protocol never confirmed completion;
yielding done for a truncated stream would falsely report success.
Custom adapters may emit {"type": REQUEST_SENT} too: the gateway does not
restrict it to built-in transports. Import it with
from korvid.agent.provider import REQUEST_SENT and use the constant, not the
literal string "REQUEST_SENT". Emit it only once the transport has accepted
the request, normally when response headers arrive, and before checking the
HTTP status; even an error response proves a handoff. Never emit it while only
preparing a payload or before credential/connection setup succeeds.
The gateway consumes this acknowledgement for payload inspection and usage accounting and never forwards it to the engine. It trusts the adapter's timing and does not independently verify network I/O. For an adapter that emits no acknowledgement, its first completion event supplies that proof instead. A failure before either event leaves the previous outbound snapshot unchanged.
Options contract, immutability, and secret policy¶
A profile's options is the only flow-specific config bag, and it accepts only
JSON-like values — null, bool, int, finite float, str, list and
nested mappings with ASCII string keys — within exact parser limits: max depth
4, max 64 mapping keys and 64 list items overall, 2048 UTF-8
bytes per string, and a 16384-byte serialized JSON budget.
Secret-looking keys are rejected before a flow sees them. The reserved segments
are exactly secret, password, token, api_key (and apikey),
authorization and credential; CamelCase keys are split at word boundaries
first, so apiKey, clientSecret and clientAPIKey are all rejected. Keep
secrets in environment variables and pass the name via auth.key.
ModelConnectionConfig deep-freezes options on construction: mappings are
read-only and sequences are tuples, including profiles loaded from
config.yaml. Build a private mutable copy if the adapter needs one.
Lifecycle and compatibility¶
- korvid reads the entry-point names at startup and imports nothing; a module loads the first time a reference resolves to its name.
- A flow owns its
prefix(or a named option on it), and a reference under a prefix it owns is never routed, so it cannot be silently bypassed. build_provider(profile)returns anLLMProviderorNone. Its sole input is the connection profile; it receives no credential source, kube client, UI handle, audit handle or write executor.- Registry load and declaration-validation failures appear in setup diagnostics. Builder failures are logged as generic construction refusals; the UI asks you to check configuration rather than displaying the builder's exception reason.
- korvid calls
LLMProvider.aclose()when the provider is replaced or at shutdown. Your provider owns the clients and credential sources its builder created: close them inaclose(), including in failure paths.
An unbuildable profile disables the agent cleanly: startup keeps korvid running with the agent off and the reason logged, and a live rebuild keeps the previous provider. Construction failures are reported by the profile factory. During a turn, unexpected provider exception text is withheld because it can contain credentials or request data; only explicitly declared operator-safe messages are shown verbatim.
Operator checklist¶
Confirm the backend truly needs a flow rather than a profile endpoint; pick a
prefix colliding with no reserved name after normalization; keep secrets out of
options; test startup and live reconfiguration; and verify every emitted
event matches the table above.