Repository navigation
[Feature]: Per-admission-key allowlist for models and providers #5049
Description
Area
Multiple areas
What are you trying to accomplish?
I run a centrally hosted OpenCodex hub used by several clients (Hermes profiles, OMP, Open WebUI, connect clients). Each client authenticates with its own admission API key (ocx_data_…). I need to restrict which models and which providers a given key may call, so a mail/cron key cannot spend Grok/Claude quota, and a coding-agent key cannot see the full hub catalog.
Today the published /v1/models catalog and provider routing are hub-global. Any valid admission key can request any published model (gldf-flash, xai/grok-4.6, combos, …).
What prevents this today?
GET /api/keys (and ocx access key list --json) returns only identity + request counters:
id,name,prefix,createdAtusage.requests7d,usage.totalRequests,usage.lastUsedAt
There is no per-key allowlist for models or providers. Hub-level models selected / combos / routing profiles apply to every key. Related roadmap #95 covers tenant isolation for a multi-user hub; this request is the narrower admission-key ACL that a single-operator hub needs without full tenancy.
Workaround today: separate hubs or “don’t give that client the URL” — not viable when one hub serves many keys.
What should OpenCodex do?
For each admission API key, allow an optional allowlist of:
- providers (e.g.
zai-discount,openrouter,combo) - models / combo aliases (e.g.
gldf-flash,google-antigravity/gemini-3.8-flash)
Observable behaviour:
- Empty / unset allowlist = current behaviour (full published catalog).
- Non-empty allowlist:
GET /v1/modelswith that key returns only allowed ids (including combo aliases).POST /v1/chat/completions(and/v1/responses,/v1/messages) for a non-allowed model returns 403 with a stable error (model_not_allowed_for_key), not a silent remap to another model.
- Management UI (Dashboard → API keys) can set the lists without rotating the secret.
- CLI can inspect and set them without printing the plaintext secret:
ocx access key get <id-or-name> --json
ocx access key set <id-or-name> --allow-provider zai-discount --allow-model gldf-flashGET /api/keysincludes the allowlists (no secret). Denied requests are visible inocx logswith the key name, requested model, andmodel_not_allowed_for_key.
Example usage or interface
Dashboard: API Keys → key hermes-mail-monitor → “Allowed providers / models”:
- providers:
zai-discount - models:
gldf-flash,combo/free
# inspect (no secret in output)
ocx access key get hermes-mail-monitor --json
# {
# "id": "9b5b3702-…",
# "name": "hermes-mail-monitor",
# "allowedProviders": ["zai-discount"],
# "allowedModels": ["gldf-flash", "combo/free"]
# }
# data plane
curl -sS https://hub.example/v1/models \
-H "Authorization: Bearer $MAIL_KEY"
# only gldf-flash and combo/free
curl -sS https://hub.example/v1/chat/completions \
-H "Authorization: Bearer $MAIL_KEY" \
-d '{"model":"xai/grok-4.6","messages":[{"role":"user","content":"hi"}]}'
# HTTP 403 {"error":{"type":"model_not_allowed_for_key","model":"xai/grok-4.6"}}A coding-agent key (hermes-nord) would keep an empty allowlist or an explicit list that includes gldf-hermes, xai/grok-4.6, etc.
Alternatives or workarounds
- Separate OpenCodex processes / hubs per client — operationally heavy (state, OAuth pools, image).
- Rely on the client not to send expensive models — clients (Hermes
/modelpicker, OMP catalog inject) discover the full hub catalog. - Hub-global
models selected— hides a model from everyone, not from one key. - [Roadmap] Centrally hosted multi-user OpenCodex with tenant isolation #95 tenant isolation — the right long-term shape; this ACL is a smaller step that unblocks single-operator multi-key hubs without tenancy.
Not requesting spend quotas or USD caps on keys in this issue (usage today has cost per upstream account / provider, not per admission key). Catalog ACL is the missing control.
Additional context
Verified on OpenCodex 2.57.0 (self-hosted hub):
GET /api/keysfields listed above — no model/provider ACL.ocx usage --jsonattributesestimatedCostUsdto providers andaccountLogLabel, not to the admission key.- Zabbix LLD around
/api/keyscan only graphrequests_7d/requests_totalper key name.
Related: #95 (tenant isolation). Happy to split Dashboard vs CLI vs data-plane 403 into follow-ups if that helps landing.
Checks
- I searched existing issues and documentation.
- This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
- I removed secrets and personal data.
Activity
Ingwannu commentedon Sep 18, 2026
Confirmed: this is a real, focused authorization gap and a reasonable first slice under #95. Configured admission already derives a trusted
apiKeyIdby comparing a server-held secret, but that identity is currently attribution only; it carries no model/provider policy.The implementation should land catalog projection and request enforcement from one immutable admission-derived policy in the same change. Filtering
/v1/modelswithout denying the corresponding send would create a misleading visibility-only promise, while denying sends without filtering would advertise unusable rows.Required design constraints:
apiKeyIdfrom the URL.Negative tests should come first: cross-key catalog isolation, direct forbidden send, alias/combo/fallback escape attempts, credential non-forwarding, policy mutation without secret rotation, and legacy-unrestricted compatibility. This does not make the proxy fully multi-tenant: account pools, affinity, usage, routing state and concurrency remain process-global until the broader #95 design is completed.
Keeping this open as an accepted design/implementation issue; no current PR is linked yet.
lidge-jun commentedon Sep 19, 2026
Implementation-path refinement from source review at
7864869c31c41cca9830d93540238f17df8faafb. This issue already owns the narrow per-admission-key authorization gap; no duplicate proposal is needed.OcxApiKeyEntryinsrc/types/config.ts:245with optional policy fields and wire their full chain: config/management input validation, persistence, reload/default handling, key rename/rotation preservation, management/CLI projection, catalog projection and request consumers.src/server/auth-cors.ts:395; caller-supplied informational identity headers are not policy authority. Keep management credentials independent.src/server/responses/request-prepare.ts:404is one repeated resolution point; inventory HTTP, WebSocket, translated inbound, combo and retry entrypoints before implementation.src/server/hub-usage.ts:8), and expose bounded denial metadata without secret material.Acceptance fixtures: unset policy preserves current behavior; allowed/denied direct models; aliases; fallback and combo re-resolution; key rotation/rename; invalid policy input; data-vs-management auth separation; and zero physical sends on denial. Register new tests in both layout manifests, update config/catalog/management/Responses structure owners, and document operator behavior. Broader tenant roles and administration remain in #95. No runtime tests or live account calls were performed for this analysis.
fix(adapters,server): output budgets, adapter queue memory, and per-k…
lidge-jun commentedon Sep 20, 2026
Partially implemented on
devby #5265 (447ac22ca65baabd573dbd429abe28ed9deff594). Keeping this open for the remaining surface.What landed: a per-admission-key scope over models and providers, evaluated against the resolved destination rather than the client's string, because alias resolution, policy and combo selection, subagent fallback and compaction override all rewrite that string. On the Responses path every produced route — direct name, alias, policy, combo child, shadow-intercept target and both subagent-fallback re-routes — passes one capture point where the check sits. Chat and Messages translate into that path; their native lanes and the compaction route apply the same predicate themselves.
/v1/modelsfilters by the same predicate and is explicitly not the boundary. A malformed scope drops the key instead of degrading to unrestricted.What is still uncovered, found by adversarial review of the branch: authenticated data-plane endpoints that spend provider quota without resolving a model through the router are not reached by the scope.
/v1/images/generationsand/v1/images/edits/v1/audio/transcriptionsand its streaming form/v1/live,/v1/realtime/callsand the standalone realtime sockets/v1/alpha/search, which forwards the caller's model to a search sidecar without routing itBudgets and request or token rate limits remain deliberately out of scope here.
fix(server): hold the images relay to the calling key's model scope
fix(server): scope the audio and voice data planes to the calling key
fix(server): scope the native voice relay to the calling key
fix(server): scope both unrouted branches of the search relay
fix(server): apply per-key model and provider scope to the unrouted d…
lidge-jun commentedon Sep 20, 2026
Closed on
dev. The scope is now applied at every path that spends provider quota.Two landings complete it. #5265 (
447ac22ca65baabd573dbd429abe28ed9deff594) put the check on the resolved destination rather than the client's string, because alias resolution, policy and combo selection, subagent fallback and compaction override all rewrite that string. On the Responses path every produced route — direct name, alias, policy, combo child, shadow-intercept target and both subagent-fallback re-routes — passes one capture point where the predicate sits; Chat and Messages translate into that path, and their native lanes and the compaction route apply the same predicate themselves./v1/modelsfilters by the same predicate and is explicitly not the boundary.An adversarial review of that branch found the gap that kept this open: authenticated data-plane endpoints that spend provider quota without resolving a model through the router. #5290 (
e37ee40addb2be759ee76ab1a5d208d671cab4d6) closes it for/v1/images/generationsand/v1/images/edits,/v1/audio/transcriptionsand its streaming form,/v1/liveand/v1/realtime/callswith the standalone realtime sockets, and the non-account-qualified branch of/v1/alpha/search. Each has its own regression file, and a malformed scope drops the key rather than degrading to unrestricted.Deliberately not included, and not a gap in this issue: budgets and request or token rate limits, and any multi-tenant or external-store conversion. Those are separate asks.
PRs here target
dev, so GitHub does not auto-close.