Provider contract#
How to write a provider package. The types are the source of truth: packages/core/src/provider.ts and packages/core/src/types.ts. This page explains the rules around them. If this page and the types disagree, the types win; fix this page in the same commit.
Package layout#
providers/<name>/
package.json name "@vid2context/provider-<name>", "vid2context": { "runtimes": [...] }
src/index.ts default export: one Provider or Provider[]
test/*.test.ts unit tests on recorded fixtures, plus LIVE=1 tests
test/fixtures/ real recorded responses, with a README saying where each came frompackage.json essentials:
{
"name": "@vid2context/provider-<name>",
"type": "module",
"exports": { ".": "./src/index.ts" },
"vid2context": { "runtimes": ["node", "workers", "browser"] },
"dependencies": { "@vid2context/core": "*" }
}runtimeslists only what the code really supports. Anything that importsnode:*modules, spawns processes or reads files is["node"].scripts/gen-registry.mjskeeps such providers out of the Workers and browser registries.- Run
npm install(ornpm run gen) after creating the package. Never editpackages/registry/src/generated/. - Add dependencies only to your own
package.json. Prefer plainfetchover vendor SDKs.
Rules for every provider#
- Use
ctx, never globals. Read config fromctx.env, make requests withctx.fetch(orfetchWithTimeout(ctx, url, init, ms)from core), log withctx.log, honourctx.signal. Never touchprocess.envor the globalfetchinside a provider. This is what makes one provider work on Node, Workers, the browser and in tests. - stdout is not yours. Log only through
ctx.log(stderr). A strayconsole.logcorrupts the MCP stdio protocol. - Throw
Vid2ContextError. Every error a user can see has a stablecodeand, where you can say how to recover, ahint. UseerrorFromResponse(res, label)for non-2xx responses: it maps 429 torate-limited(withRetry-After), 401/402/403 toauth, 404/410 tonot-found, 413 totoo-large, other statuses toupstream. - Input errors stop the chain. Throw
bad-requestorunsupported-inputonly when the caller's input is wrong and no other rung could do better. Everything else falls through to the next rung. - No placeholders. No mock data, no TODO stubs, no fake responses. If something cannot be built yet, leave it out and say so in the docs.
Source providers (kind: 'source')#
| Member | Contract |
|---|---|
match(input) |
Synchronous, cheap, no network. Return a VideoRef or null. Filesystem stat is acceptable for local paths. |
catchAll |
true for providers that accept almost any URL (yt-dlp, web page discovery). The registry tries specific sources first. |
getMetadata(ref, ctx) |
Return VideoMetadata; chapters is always an array (empty when none). Throw not-found, unavailable or blocked when the video itself cannot be had; the pipeline stops on those. Other errors make the pipeline fall back to minimal metadata. |
listItems(ref, ctx, {limit}) |
Async iterable of video refs for playlist, channel, feed and page refs. Stream; do not buffer the whole list. |
canProvideAudio(ref, ctx) / getAudio(ref, ctx) |
Audio for speech-to-text. A provider can supply audio for refs from other platforms (yt-dlp does), so the pipeline asks every source whose canProvideAudio returns true. |
getComments(ref, ctx, {limit}) |
Optional. |
VideoRef rules:
idis${platform}:${nativeId}and is the storage and cache key. Lists use${platform}:${kind}:${nativeId}, for exampleyoutube:playlist:PL....platformis a short slug:youtube,vimeo,file,podcast,web.urlis canonical when one exists.timestampUrlin core turns it into deep links.
Captions providers (kind: 'captions')#
supports(ref)is synchronous.getCaptions(ref, opts, ctx)returns the bestTranscriptforopts, ornullwhen the video has no usable track. Throw only on real failures (network, blocking).- Respect
opts.language,opts.allowAuto(default true) andopts.allowTranslated(default true). A machine-translated result setstranslated: trueand keeps the source track'sorigin. originiscreator-captions,auto-captionsornative-transcript.
Rung providers (stt, llm, embed, video, visual)#
A rung is one provider + one model + one credential. Providers build rungs from env on every call:
rungs(env: Env): SttRung[] // and LlmRung[], EmbedRung[], VideoRung[], VisualRung[]rungs(env) must be pure and fast (no network). Return every rung the provider knows, enabled or not, so /api/v1/health can show what a key would unlock.
Rung fields#
| Field | Rule |
|---|---|
id |
<kind>/<service>/<model>, e.g. llm/groq/openai/gpt-oss-120b, stt/ovh/whisper-large-v3-turbo. A rung with no model configured yet is <kind>/<service>. Cooldowns are keyed by id, so keep it stable. |
service |
Upstream name: groq, ovh, ollama. The nth pooled key is groq#n (n from 2). VID2CONTEXT_SKIP matches services. |
tier |
local (this machine), free (hosted free tier with your key), keyless (hosted, no key), paid. |
priority |
Lower runs first within a tier. Leave gaps of 10 between lanes. |
quality |
1 (rough) to 5 (best). Used by the quality policy. |
capabilities |
See the vocabulary below. rankRungs filters on needs. |
requires |
Env var names that must be set. Empty for keyless and most local rungs. |
isEnabled(env) |
Optional synchronous override of the requires check. |
Capability vocabulary (add new words here when you need them):
| Kind | Capabilities |
|---|---|
| llm | json (json_object), json-schema (strict structured output), tools, vision, stream |
| stt | segments, words, language-detect, prompt, diarization, translate |
| video | visual-notes, transcript |
| visual | ocr, describe |
Kind-specific members: SttRung.maxBytes (largest upload; the pipeline skips rungs whose limit is below the audio size), LlmRung.contextTokens (usable window on that tier), EmbedRung.dimensions.
Env var conventions#
| Pattern | Meaning |
|---|---|
<PROVIDER>_API_KEY |
One key. Keep it a single value. |
<PROVIDER>_FALLBACK_KEYS |
Extra comma-separated keys. keyPool(env, '<PROVIDER>_API_KEY') returns all of them, de-duplicated; make one rung per key with pooledService(service, index). |
<PROVIDER>_MODEL |
LLM model list for that provider, comma-separated, in order. Replaces the default list. |
<PROVIDER>_STT_MODEL, <PROVIDER>_EMBED_MODEL |
Same for speech-to-text and embeddings. |
<PROVIDER>_BASE_URL |
Base URL override where a provider can be self-hosted or proxied. |
VID2CONTEXT_* |
Reserved for vid2context itself (policy, skip, keyless switch, log level, env file). |
Every new env var goes into .env.example (commented, optional) and docs/providers.md in the same commit.
Keyless lanes#
Keyless rungs send data to a third party without an account. They are on by default (the zero-config path) and VID2CONTEXT_KEYLESS=0 turns them all off. Never send an Authorization header to a keyless endpoint: OVH and Pollinations reject bogus ones.
Model ids#
Free model ids are retired without notice. Pin ids only where the provider keeps them stable, make every id overridable through env, and never pin an OpenRouter :free id (pick from the live list instead).
Streaming#
An LLM rung streams by also implementing StreamingLlmRung from packages/core/src/llm-stream.ts: an LlmRung plus stream(req, ctx): AsyncIterable<ChatStreamEvent>. canStream(rung) tells them apart, and listModels adds the stream capability to such rungs (do not list stream in capabilities yourself). Rungs without stream still work behind a streaming UI: streamFromRung calls chat() and emits the whole answer as one delta.
Events a rung yields, in order:
| Event | Rule |
|---|---|
{ type: 'reasoning', text } |
Zero or more. Chain of thought when the upstream exposes it (delta.reasoning_content, delta.reasoning, inline <think>). Never mixed into the answer. |
{ type: 'delta', text } |
One or more. Answer text in order. Their concatenation is the final text. |
{ type: 'done', result } |
Exactly one, last. result is a ChatResult: full text, model as the upstream reported it, usage when sent. |
Errors are thrown, never yielded: a non-2xx before the stream goes through errorFromResponse, an error object inside the stream becomes a Vid2ContextError, a body that ends with neither [DONE] nor a finish_reason is an upstream error, and an empty answer is an upstream error (as in chat()).
Fallback rule (chatStream in packages/core/src/llm/stream.ts): rungs are ranked exactly as chat() ranks them. A rung that fails before its first delta is skipped and the next one is tried, with the chain's cooldowns (opening a stream runs through runChain, so a 429 cools the rung down for chat() too). After the first delta the stream never switches rungs: a failure ends it with an error that carries attempts. chatStream adds a leading { type: 'meta', rung, model, contextTokens? } event, sent again with restart: true only when a rung failed after reasoning but before any answer text, and its done also carries rung and attempts.
How a provider adds streaming:
- OpenAI-compatible APIs: call
streamOpenAiChat({ url, model, key?, label?, headers?, body? }, req, ctx)from@vid2context/core. Passbody: (req) => <your non-streaming body builder>so both paths send the same request; it addsstream: trueandstream_options: { include_usage: true }, and retries once withoutstream_optionswhen a service answers 400 naming it (setnoStreamOptions: trueto skip it from the start). - Other wire formats: parse the body with
parseSSE(res.body, signal)from@vid2context/coreand yield the events above. - Type the rung object as
StreamingLlmRungwhere you build it, then return it in theLlmRung[]. - Test with real recorded stream bodies (raw bytes) replayed through
replayFetch; never hand-write an SSE stream.
Platform bindings (ctx.bindings)#
ctx.bindings is an optional Record<string, unknown> of platform objects that are not strings: on Cloudflare Workers, createWorkersContext(env) puts every non-string env value there (the AI, D1, R2, KV and Vectorize bindings) and keeps strings in ctx.env. On Node and in the browser it is usually absent.
- Treat it as optional.
rungs(env)andisEnabled(env)see onlyenv, so a provider that needs a binding is switched on by a string env var naming the binding (for exampleCLOUDFLARE_AI_BINDING=AI,VID2CONTEXT_D1=DB) and readsctx.bindings[name]at call time. - When the named binding is missing or has the wrong shape, throw a
Vid2ContextErrorwhose hint names the binding and thewrangler.jsoncentry that creates it. - Never copy bindings into logs, attempts or error messages.
Telemetry (ctx.telemetry)#
ctx.telemetry is an optional Telemetry from packages/core/src/telemetry/types.ts: spans, counters, histograms, events and an optional usage ledger. The chain and the pipeline already record a span per stage and per rung attempt, so providers need do nothing.
- Treat it as optional. When it is absent, do nothing and allocate nothing.
- A provider may add detail with
ctx.telemetry?.event(name, attrs)inside its call. Attribute values are ids, counts and flags only: never transcript text, prompts, URLs with query strings, keys or any env value. - Report usage on the result, not through telemetry: LLM rungs fill
ChatResult.usage; the chain turns it into token counters and paid cost.
Storage providers (kind: 'storage')#
isEnabled(env) decides from env alone; the enabled backend with the lowest priority wins. create(ctx) returns an object implementing Storage from packages/core/src/storage.ts. Unknown ids throw Vid2ContextError('not-found'). Raw transcripts are always stored; summaries and briefs are stored next to them, never instead of them.
Testing#
Unit tests replay real recorded responses; they never reach the network and never use invented payloads.
import { replayFetch, testContext } from '@vid2context/core/testing';
const fetch = replayFetch([
{ method: 'POST', url: 'https://api.example.com/v1/audio/transcriptions', response: { body: fixture('recorded.json') } },
{ url: /timedtext\?v=jNQXAC9IVRw/, response: { status: 429, headers: { 'retry-after': '31' }, body: fixture('429.html') } },
]);
const ctx = testContext({ fetch, env: { GROQ_API_KEY: 'gsk_test' } });replayFetchthrows on any request without a recorded response and keeps every call in.calls(URL, method, headers, body) for assertions.testContextnever readsprocess.env; pass the env the test needs.- Live tests:
describe.skipIf(!process.env.LIVE)(...)withcreateContext({ runtime: 'node', env: process.env }). Run them withnpm run test:live. - Record fixtures from the real service, then trim anything personal before committing: stream URLs that embed your IP, emails, phone numbers, keys. Grep the fixture for your public IP. Document each file in
test/fixtures/README.md(source, date, what was trimmed). - Shared real fixtures live in
tests/fixtures/(a public-domain mp3 and realVideoContextJSON). Read them; do not edit them. - When a credential is missing, test against recorded fixtures and the keyless or local lane, and say in your final report what could not be verified live.
Reference implementations#
| Package | Shows |
|---|---|
| providers/youtube | source + captions, per-ctx response cache, playability errors, block detection |
| providers/stt-openai-compatible | multipart upload, key pools, model overrides, keyless lane |
| providers/llm-openai-compatible | lane table, per-model rungs, JSON modes, reasoning-model answers |
| providers/media-file | node-only source, filesystem refs |