docs/custom-provider-design.md
Status: accepted design boundary. This document defines a bounded MVP; it does not authorize runtime networking or implement the feature.
Issue: #1735
A declarative provider can reduce one-off integrations, but it is not a small extension of LLM Proxy or LiteLLM.
Those providers still have compile-time UsageProvider identities, descriptors, implementations, request shapes, and response
decoders. A custom provider adds two new trust boundaries:
Accepted direction: pursue a config-only, GET-only, HTTP JSON MVP after separating runtime provider instance identity
from the closed UsageProvider enum. Do not add a single .custom enum case: multiple configured providers would then
collide in caches, status items, history, widgets, and settings.
ProviderConfig.id decodes directly as UsageProvider.ProviderDescriptorRegistry bootstraps exactly one descriptor for every UsageProvider.allCases value.ProviderImplementationRegistry constructs implementations with an exhaustive UsageProvider switch.UsageProvider across the app.provider:<UsageProvider.rawValue> and still assumes one
pane per compile-time provider, reinforcing that dynamic identities need the shared seam rather than a parallel UI path.ProviderEndpointOverrideValidator already provides hardened HTTPS host parsing and an explicit loopback-HTTP mode,
while ProviderHTTPClient limits redirects to the same HTTPS origin. Reuse those primitives, but add a custom-provider
policy for fragments, auth-dependent loopback rules, redirect rejection, response limits, and secret-safe errors.Keep the existing provider array and introduce config version 2 with a tagged provider definition. Existing entries remain first-party definitions; custom entries have a stable user-chosen instance ID and a fixed implementation kind.
{
"version": 2,
"providers": [
{
"id": "acme-gateway",
"kind": "custom-http-json",
"label": "Acme Gateway",
"enabled": true,
"request": {
"method": "GET",
"url": "https://gateway.example.com/v1/quota",
"authentication": { "type": "bearer" }
},
"mapping": {
"primary": {
"usedPercent": { "path": "quota.used_pct" },
"resetsAt": { "path": "quota.reset_at", "dateFormat": "iso8601" },
"windowMinutes": { "path": "quota.window_minutes" }
},
"cost": {
"used": { "path": "spend.usd" },
"currency": "USD",
"period": "Approx. spend"
},
"identity": {
"organization": { "path": "plan.name" },
"loginMethod": { "literal": "api" }
}
}
}
]
}
Rules:
id: lowercase ASCII letters, digits, and hyphens; 1–64 characters; unique across first-party and custom providers.label: required, trimmed, 1–80 characters. MVP uses a built-in generic icon.method: only GET.authentication: none, bearer, or x-api-key; the secret is never inline. Authenticated instances read only their
derived variable CODEXBAR_CUSTOM_<INSTANCE_ID>_API_KEY, with the ID uppercased and hyphens replaced by underscores.
A definition cannot name an arbitrary environment variable or header; bearer uses Authorization and x-api-key uses
X-API-Key.mapping.primary: optional. When present, requires exactly one of usedPercent or remainingPercent. Optional fields
are omitted when their paths are missing or null.mapping.cost: when present, requires used; currency is a three-letter uppercase literal and period is a bounded
literal. A missing limit maps to zero, matching existing sparse cost snapshots.mapping.identity: optional bounded strings. The configured provider instance ID, not response data, owns snapshot
identity.Use a typed dot-path subset, not JSONPath, jq, JavaScript, predicates, or string interpolation.
Grammar:
path = segment *("." segment / "[" index "]")
segment = ALPHA *(ALPHA / DIGIT / "_" / "-")
index = 1*DIGIT
Each target field determines its accepted type. Number coercion accepts JSON numbers and finite numeric strings only.
Percentages are clamped to 0–100 after rejecting NaN and infinity. Dates require an explicit format: iso8601,
unix-seconds, or unix-milliseconds. Display strings are trimmed and length-limited. Missing optional paths do not fail
the whole snapshot; a present value with the wrong type does.
No wildcards, recursive descent, filters, arithmetic, template evaluation, or user-supplied code. Multi-window arrays and aggregation are later design work.
Hard limits: 16 mapped leaves per definition; 256 UTF-8 bytes and 32 components per path; 64 ASCII characters per segment; array indices 0–4095; response JSON nesting depth 64; mapped display strings 256 UTF-8 bytes. Validate these limits before traversal. Preflight structural nesting directly on the bounded response bytes with an iterative, string-aware scanner before materializing JSON, so a hostile nested payload cannot exhaust the call stack.
localhost,
127.0.0.0/8, or ::1). Reject URL user info and fragments.ProviderEndpointOverrideValidator; do not create a second URL parser. Use a dedicated
ProviderHTTPClient configuration that rejects every redirect, even though the shared client safely permits same-origin
HTTPS redirects..local, and visibly private targets require typing the normalized URL instead of accepting a button. Any
bound-field change invalidates approval before the next fetch. This gate applies to unauthenticated loopback HTTP too.URLSessionConfiguration.ephemeral session with httpCookieStorage = nil,
httpShouldSetCookies = false, urlCredentialStorage = nil, urlCache = nil, and a reload-ignoring-local-cache policy.
Do not share a session with first-party providers. A dedicated challenge handler allows normal server-trust evaluation
only and cancels client-certificate or HTTP authentication challenges.Accept-Encoding: identity, reject a non-identity Content-Encoding, and enforce the streaming 1 MiB cap on bytes
delivered after URL loading's decoding and before JSON materialization. Cancel the task when the cap is exceeded. Use a
15-second total timeout and a JSON content-type check.Introduce a ProviderInstanceID string value that identifies one configured instance. Keep UsageProvider as the
compile-time implementation kind for first-party providers.
ProviderDefinition
firstParty(instanceID, UsageProvider, ProviderConfig)
customHTTPJSON(instanceID, CustomHTTPJSONConfig)
Migrate runtime dictionaries, persistence keys, ProviderIdentitySnapshot.providerID, and identity accessors that
represent an enabled provider instance to ProviderInstanceID. First-party instance IDs retain their current raw values,
preserving existing config and stored history. Provider-specific fetchers continue to receive UsageProvider; the custom
fetcher receives only its validated custom definition. Provider-specific identity payloads remain keyed by their
compile-time implementation kind, while shared organization and login-method fields belong to the provider instance.
This seam must land independently with characterization tests before the custom network path. It prevents a custom provider from being threaded through exhaustive first-party switches or sharing state with another custom instance.
| Threat | Required mitigation |
|---|---|
| Shared or malicious config exfiltrates a secret | Dedicated per-instance variable; separate full-URL/auth approval before secret resolution; config changes invalidate approval; redirects disabled |
| Endpoint redirects auth to another host | Treat every redirect as failure |
| Shared config silently probes or changes a GET target | No network access before separate full-URL approval; any URL change invalidates it; no bulk approval; elevated confirmation for visibly local/private targets |
| Hostile JSON causes CPU or memory pressure | 1 MiB cap; bounded depth and path length; no recursive expressions; request timeout |
| Response injects misleading or huge menu text | Typed targets; numeric bounds; string trimming and length limits; configured identity wins |
| Secret or response leaks through diagnostics | Redacted request description; no headers or raw response body in errors/logs |
| Two custom providers overwrite one another | Stable ProviderInstanceID keys throughout runtime and persistence |
| Config silently changes first-party behavior | Tagged definition; versioned decoder; duplicate/reserved ID rejection; migration tests |
Out of scope: defending a user from a request URL they explicitly approved, including a public hostname that later resolves to a local or private address. Approval grants that origin network authority for the approved URL; the confirmation must state this clearly. CodexBar still must contain the service response and must never disclose unrelated credentials.
ProviderInstanceID; migrate config/runtime/persistence keys without behavior changes; add
decode, history, enablement, menu, CLI, and widget characterization tests.UsageSnapshot mapping using only
fixture data.codexbar config validate, local approval records and interactive
approval command, diagnostics, and custom-provider CLI output. No live credentials in tests.Each slice should be a separately reviewable PR. Do not combine the identity migration and arbitrary networking in one change.
.local, and visibly private targets.make test, make check, structured autoreview, and exact-head CI are green for every implementation PR.Implementation gate: keep custom-provider networking disabled until the independent identity migration, pure evaluator,
bounded transport, approval flow, and their required proof land as separately reviewable changes. If an implementation
cannot preserve this boundary, stop rather than shipping a single .custom slot, a parallel UI path, or a compatibility
fallback.