One endpoint in front of every free LLM tier, with automatic failover and no provider key ever leaving the box. Providers are data, not code: adding one is a JSON file plus an env var, never a TypeScript change. Ships 12 providers pre-written; `verify-provider --apply` derives the live model list from your own key. - core: registry, bucketed quota meter (rolling + UTC-calendar windows), key-pool store, 6-step model router with diagnostic rejection reasons - gateway: node:http, failover that commits to a stream only after 200 headers, SSE passthrough with usage sniffing, virtual keys with admin/inference roles - context engine: sqlite sessions and a 5-rung refit ladder, so a 128k thread keeps working when it fails over to an 8k model - cache: exact (sha256, safe-by-default) + opt-in semantic (flat Float32Array) - guard: inbound limits, quota forecasting, limit calibration from upstream headers, key health, secret/PII/injection scanning - dashboard: one self-contained HTML file, live SSE request log, token usage - sdk: zero-dep typed client, drop-in for the OpenAI SDK Zero runtime dependencies. 326 tests, 27-check smoke suite against a scripted mock upstream so routing is testable without spending quota. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
7.7 KiB
JSON
143 lines
7.7 KiB
JSON
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$id": "https://freeway.local/schemas/provider.json",
|
|
"title": "Freeway provider spec",
|
|
"description": "One free-tier API provider, as data. Adding a provider means dropping one of these into providers/ and setting an env var — never editing TypeScript. Files whose name starts with _ are skipped by the loader, which is why this schema can live here.",
|
|
"type": "object",
|
|
"required": ["id", "baseUrl", "auth"],
|
|
"additionalProperties": false,
|
|
|
|
"properties": {
|
|
"$schema": { "type": "string" },
|
|
|
|
"id": {
|
|
"type": "string",
|
|
"pattern": "^[a-z0-9][a-z0-9-]*$",
|
|
"description": "Short slug. Becomes the first half of every model ref, e.g. `groq/llama-3.3-70b`."
|
|
},
|
|
"label": { "type": "string", "description": "Human name for the dashboard. Defaults to id." },
|
|
"docs": { "type": ["string", "null"], "description": "API docs URL, linked from the provider card." },
|
|
"console": { "type": ["string", "null"], "description": "Where to get a key. Linked as 'get a key' on unconfigured cards." },
|
|
"enabled": { "type": "boolean", "default": true },
|
|
"priority": { "type": "number", "default": 50, "description": "Lower is tried first under the `priority` strategy." },
|
|
"adapter": { "enum": ["openai"], "default": "openai", "description": "Wire format. Only `openai` is implemented." },
|
|
|
|
"baseUrl": {
|
|
"type": "string",
|
|
"description": "OpenAI-compatible base, e.g. https://api.groq.com/openai/v1. May contain ${ENV_VAR}, which is how providers that embed an account id in the URL stay pure data.",
|
|
"examples": [
|
|
"https://api.groq.com/openai/v1",
|
|
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1"
|
|
]
|
|
},
|
|
|
|
"auth": {
|
|
"type": "object",
|
|
"required": ["type"],
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"type": {
|
|
"enum": ["bearer", "header", "query", "none"],
|
|
"description": "bearer → Authorization: Bearer <key>. header → a named header. query → a query param. none → no credential (a local Ollama)."
|
|
},
|
|
"header": { "type": ["string", "null"], "description": "Header name when type is `header`, e.g. x-api-key." },
|
|
"query": { "type": ["string", "null"], "description": "Query param name when type is `query`, e.g. key." },
|
|
"prefix": { "type": ["string", "null"], "description": "Overrides the `Bearer ` prefix. Set \"\" for a bare token." },
|
|
"envKeys": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "Env vars to read keys from, in order. ONE variable may hold several comma-separated keys — that is the key pool, and quota is tracked per key.",
|
|
"examples": [["GROQ_API_KEY", "GROQ_API_KEY_2"]]
|
|
}
|
|
}
|
|
},
|
|
|
|
"limits": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"description": "Free-tier ceilings. null means unknown or unlimited and is ALWAYS better than a guess — the guard layer overwrites nulls with values learned from upstream rate-limit headers.",
|
|
"properties": {
|
|
"rps": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Requests per second (rolling)." },
|
|
"rpm": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Requests per minute (rolling 60s)." },
|
|
"rpd": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Requests per day (calendar, resets 00:00 UTC)." },
|
|
"tpm": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Tokens per minute (rolling 60s)." },
|
|
"tpd": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Tokens per day (calendar UTC)." },
|
|
"tpmo": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Tokens per calendar month." },
|
|
"creditsPerDay": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Abstract metered unit per day — Cloudflare bills free Workers AI in neurons." },
|
|
"creditsPerRequest": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Fallback flat credit cost when a model has no per-token rates." }
|
|
}
|
|
},
|
|
|
|
"limitsSource": {
|
|
"enum": ["docs", "observed", "unverified"],
|
|
"default": "unverified",
|
|
"description": "Where the numbers above came from. Use `unverified` honestly — the dashboard badges it and nothing breaks."
|
|
},
|
|
"verifiedOn": { "type": ["string", "null"], "description": "ISO date the limits were last checked, e.g. 2026-08-16." },
|
|
|
|
"modelsEndpoint": {
|
|
"type": ["string", "null"],
|
|
"default": "/models",
|
|
"description": "Path for live model discovery, relative to baseUrl. null disables it (Cloudflare's OpenAI surface has no /models)."
|
|
},
|
|
"modelsPath": {
|
|
"type": "string",
|
|
"default": "data[].id",
|
|
"description": "Where ids live in the discovery response. Tiny grammar: `data[].id`, `models[].name`, `[].id`, `result[].name`. This is what keeps a non-OpenAI response shape a JSON edit.",
|
|
"examples": ["data[].id", "models[].name", "result[].name", "[].id"]
|
|
},
|
|
|
|
"dropParams": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "Body params this provider rejects; stripped before forwarding. Find them by sending a request and reading the 400.",
|
|
"examples": [["frequency_penalty", "presence_penalty"]]
|
|
},
|
|
"headers": {
|
|
"type": "object",
|
|
"additionalProperties": { "type": "string" },
|
|
"description": "Extra request headers. Values may contain ${ENV_VAR}."
|
|
},
|
|
"notes": { "type": ["string", "null"], "description": "Anything the next reader needs — signup friction, quirks, why a number is what it is." },
|
|
|
|
"models": {
|
|
"type": "array",
|
|
"description": "Seed models. Prefer a handful you have actually verified over a long list you have not; live discovery fills in the rest.",
|
|
"items": {
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"id": { "type": "string", "description": "Upstream model id, exactly as the provider spells it. May contain slashes." },
|
|
"label": { "type": "string" },
|
|
"alias": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "Short names clients can route by. Several providers sharing an alias is the point — that is what makes failover work.",
|
|
"examples": [["fast", "cheap"]]
|
|
},
|
|
"context": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Context window in tokens. null when unpublished; the context engine then assumes a conservative default." },
|
|
"caps": {
|
|
"type": "array",
|
|
"items": { "enum": ["chat", "tools", "json", "vision", "reasoning", "embed", "code", "long-context"] },
|
|
"default": ["chat"],
|
|
"description": "Drives routing. A request with `tools` only goes to models declaring `tools`. `embed` models never serve chat and vice versa."
|
|
},
|
|
"priority": { "type": "number", "default": 50 },
|
|
"enabled": { "type": "boolean", "default": true },
|
|
"maxOutput": { "type": ["number", "null"], "exclusiveMinimum": 0 },
|
|
"credits": {
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"description": "Per-model credit rates. Output tokens typically cost 7-10x input, and a big model ~10x a small one, so one flat number per provider is wrong by an order of magnitude.",
|
|
"properties": {
|
|
"perMTokIn": { "type": ["number", "null"], "exclusiveMinimum": 0 },
|
|
"perMTokOut": { "type": ["number", "null"], "exclusiveMinimum": 0 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|