feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(model): split provider_config inline into ProviderKey reference - #102

Merged
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref
May 7, 2026
Merged

feat(model): split provider_config inline into ProviderKey reference#102
moonming merged 7 commits into
mainfrom
feat/model-provider-key-ref

Conversation

@moonming

@moonmingmoonming commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Realigns the standalone Model schema with the AISIX-Cloud control plane's normalised shape — the projection cp-api has been waiting for since PRD-09b §6.

cp-api's mustMarshalModelKV literally has this comment:

Phase 2 swaps to {model, provider_key_id} with DP-side join, but requires Model.provider_config refactor across 26 DP files which is a separate PR.

This is that PR.

Schema change

Old (pre-#95 + this PR):

{ "name": "...", "model": "<provider>/<id>", "provider_config": { "api_key": "...", "api_base": "..." } }

New:

{ "display_name": "...", "provider": "openai|anthropic|gemini|deepseek", "model_name": "...", "provider_key_id": "<uuid>" }

provider_key_id references a ProviderKey row (top-level resource introduced in #95) carrying secret + api_base. Routing models keep the same routing block but drop the upstream triple — the router resolves a target Model and dispatches against THAT model's provider_key_id.

JSON Schema enforces the direct-vs-routing XOR via oneOf:

  • Direct ⇒ all three of provider/model_name/provider_key_id required
  • Routing ⇒ all three forbidden, routing required

Why

  • One ProviderKey, many Models. Rotating the upstream secret used to require rewriting every Model row that embedded it; now it's a single PUT against the ProviderKey.
  • AISIX-Cloud parity. cp-api already has a provider_keys table; managed-mode DPs need this shape to consume what cp-api projects into kine.
  • Snapshot-table integrity. The DP can validate at load time that every Model.provider_key_id resolves to a ProviderKey in the same snapshot, instead of carrying inline secrets it can't cross-check.

Changes by area

aisix-core

  • Model: new {display_name, provider: Option<Provider>, model_name: Option<String>, provider_key_id: Option<String>}. Removed ProviderConfig struct.
  • JSON Schema: oneOf for direct/routing XOR.
  • Resource::name() returns &display_name.

aisix-gateway

  • BridgeContext gains provider_key: Arc<ProviderKey>. Constructor sig: new(request_id, model, provider_key).

aisix-provider-{openai,anthropic,gemini,deepseek}

  • Bridge helpers (resolve_base / api_key / upstream_model) take &BridgeContext and read from ctx.provider_key + ctx.model.

aisix-proxy

  • New dispatch.rs resolves both Model and ProviderKey from the snapshot before each per-endpoint handler builds BridgeContext.
  • Every endpoint (chat / completions / embeddings / messages / responses / rerank / images / audio / passthrough) updated.
  • 422 with a clear error envelope when Model.provider_key_id doesn't resolve.

Tests + fixtures — ~30 files across the workspace updated to the new JSON shape.

Test plan

Cross-repo follow-up

AISIX-Cloud's mustMarshalModelKV (internal/cpapi/resources/handlers.go) needs to switch from writing the inline provider_config shape to the new shape. Will track separately.

Summary by CodeRabbit

  • New Features

    • Admin API now supports ProviderKey resources (create via Admin) and model cost fields (input_per_1k, output_per_1k).
  • Bug Fixes

    • Enforced mutual exclusivity between routing and direct model configs.
    • Improved validation and clearer error mapping for missing/invalid provider or provider-key.
  • Refactor

    • Model schema migrated to display_name/provider/model_name/provider_key_id.
    • Proxy and bridges now resolve provider keys separately and select upstreams via provider-key.

@coderabbitai

coderabbitaiBot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request refactors the Model domain struct to externalize provider credentials and configuration from the Model into ProviderKey resources; Model fields change from (name, model, provider_config) to (display_name, provider, model_name, provider_key_id). ProviderConfig is removed. A new proxy dispatch module centralizes ProviderKey resolution, secret validation, and base URL override/fallback. BridgeContext is extended to carry both Model and ProviderKey; bridges and proxy endpoints are updated to derive upstream config from ProviderKey. Tests and fixtures across admin, proxy, gateway, providers, etcd, and e2e are migrated to the new shape and helpers.

Changes

Model Domain Refactoring

Layer / File(s)Summary
Domain Schema & Validation
crates/aisix-core/src/models/model.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/snapshot.rs
Model struct refactored from name/model/provider_config to display_name/provider: Option/model_name: Option/provider_key_id: Option. ProviderConfig removed. JSON schema updated with oneOf to enforce routing vs direct fields and a new cost object; sample fixtures and schema tests updated.
Public API Re-exports
crates/aisix-core/src/lib.rs, crates/aisix-core/src/models/mod.rs
ProviderConfig removed from public re-exports; Provider remains exported.
BridgeContext Expansion
crates/aisix-gateway/src/bridge.rs, crates/aisix-gateway/src/hub.rs
BridgeContext extended with provider_key: Arc<ProviderKey>; constructor now requires provider_key; gateway/hub tests updated to pass ProviderKey.
Dispatch Helper Module
crates/aisix-proxy/src/dispatch.rs
New module adds resolve_provider_key, require_provider, require_upstream_model, resolve_base_url, and require_secret to centralize ProviderKey resolution, upstream model extraction, secret validation, and base URL override/fallback. Unit tests added.
Admin CRUD & Store
crates/aisix-admin/src/models_handlers.rs, crates/aisix-admin/src/health_handler.rs, crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
Uniqueness checks and health lookups now use display_name; test fixtures and integration tests updated to create/assert the new model JSON shape.
Provider Bridge Implementations
crates/aisix-provider-openai/src/bridge.rs, crates/aisix-provider-anthropic/src/bridge.rs, crates/aisix-provider-deepseek/src/lib.rs, crates/aisix-provider-gemini/src/lib.rs
Bridges now derive api_base, secret, and upstream model from BridgeContext.provider_key and Model.model_name; private helper functions added; tests updated to construct contexts with ProviderKey.
Proxy Endpoint Dispatch
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/completions.rs, crates/aisix-proxy/src/embeddings.rs, crates/aisix-proxy/src/images.rs, crates/aisix-proxy/src/audio.rs, crates/aisix-proxy/src/passthrough.rs, crates/aisix-proxy/src/rerank.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/models.rs, crates/aisix-proxy/src/responses.rs
Endpoints refactored to use dispatch helpers for provider/provider-key/base resolution; BridgeContext construction updated to require resolved provider_key; passthrough provider selection now matches model.provider; tests and fixtures updated to provider-key-backed snapshots and helpers.
Test Infrastructure & E2E
crates/aisix-proxy/src/lib.rs, crates/aisix-admin/src/playground_handler.rs, tests/e2e/*
Test helpers centralized: PK_ID, model_entry, provider_key_entry, new_snap, per-provider new_snap_* helpers; AdminClient.createProviderKey added to e2e harness; fixtures in many tests migrated to provider-key-backed pattern.

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

CopilotAI review requested due to automatic review settings May 7, 2026 05:40

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors the Model resource to stop embedding provider credentials inline and instead reference a standalone ProviderKey via provider_key_id, aligning DP schemas and proxy dispatch with the normalized control-plane shape.

Changes:

  • Updated aisix-coreModel struct + JSON Schema to the new {display_name, provider, model_name, provider_key_id} shape with a routing-vs-direct oneOf XOR.
  • Added proxy-side dispatch helpers to resolve ProviderKey + compute base URLs, and updated all proxy endpoints/bridges to use BridgeContext { model, provider_key }.
  • Updated admin/API handlers, etcd loader tests, provider bridges, and e2e fixtures to seed ProviderKey resources and reference them from models.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
FileDescription
tests/e2e/src/harness/admin.tsAdds e2e helper for creating ProviderKey via admin API.
tests/e2e/src/cases/smoke.test.tsUpdates smoke test to create a ProviderKey and reference it from Model.
crates/aisix-proxy/src/responses.rsSwitches /v1/responses dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/rerank.rsSwitches rerank dispatch to use ProviderKey.secret and api_base.
crates/aisix-proxy/src/passthrough.rsUses provider-based model selection to find a ProviderKey for passthrough calls.
crates/aisix-proxy/src/models.rsLists models using display_name and new provider field access.
crates/aisix-proxy/src/messages.rsSwitches /v1/messages dispatch to resolve ProviderKey and new model fields.
crates/aisix-proxy/src/lib.rsWires new dispatch module and updates proxy routing tests to seed provider keys.
crates/aisix-proxy/src/images.rsBuilds BridgeContext with provider_key for images endpoint.
crates/aisix-proxy/src/embeddings.rsBuilds BridgeContext with provider_key for embeddings endpoint.
crates/aisix-proxy/src/dispatch.rsNew shared helpers: resolve ProviderKey, require provider/model_name, resolve base URL, require secret.
crates/aisix-proxy/src/completions.rsBuilds BridgeContext with provider_key for completions endpoint.
crates/aisix-proxy/src/chat.rsUpdates streaming + routing paths to resolve ProviderKey and use display_name.
crates/aisix-proxy/src/audio.rsUpdates audio endpoints to resolve ProviderKey and compute base URL from it.
crates/aisix-provider-openai/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-provider-gemini/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-deepseek/src/lib.rsUpdates provider tests to construct BridgeContext with a ProviderKey.
crates/aisix-provider-anthropic/src/bridge.rsReads secret/api_base from ctx.provider_key and upstream model from ctx.model.model_name.
crates/aisix-gateway/src/hub.rsUpdates hub test context to include ProviderKey.
crates/aisix-gateway/src/bridge.rsExtends BridgeContext to include provider_key and updates tests accordingly.
crates/aisix-etcd/src/supervisor.rsUpdates etcd supervisor tests’ model fixtures to the new schema.
crates/aisix-etcd/src/loader.rsUpdates loader tests for new model schema and provider enum validation.
crates/aisix-core/src/models/snapshot.rsUpdates snapshot tests’ model fixture to the new schema.
crates/aisix-core/src/models/schema.rsReplaces legacy model schema with new fields + direct-vs-routing oneOf XOR and cost block.
crates/aisix-core/src/models/model.rsRefactors Model struct, removes ProviderConfig, updates docs/tests and Resource::name().
crates/aisix-core/src/models/mod.rsUpdates exports to drop ProviderConfig.
crates/aisix-core/src/lib.rsUpdates public re-exports to drop ProviderConfig.
crates/aisix-admin/tests/etcd_integration.rsUpdates admin etcd integration tests’ model payloads to new fields.
crates/aisix-admin/src/store.rsUpdates store tests and model field references to display_name.
crates/aisix-admin/src/playground_handler.rsUpdates playground handler tests to seed ProviderKey and reference it from Model.
crates/aisix-admin/src/models_handlers.rsEnforces uniqueness on display_name instead of legacy name.
crates/aisix-admin/src/lib.rsUpdates admin API tests to the new model payload and assertions.
crates/aisix-admin/src/health_handler.rsUses display_name for health lookup and response output.
crates/aisix-admin/src/etcd_store.rsUpdates etcd store tests and assertions to display_name.
Comments suppressed due to low confidence (1)

crates/aisix-proxy/src/rerank.rs:131

  • Base URL resolution logic is now duplicated here instead of reusing crate::dispatch::resolve_base_url(...). This increases the risk of base URL normalization drifting across endpoints (e.g., trimming rules, blank handling). Prefer centralizing the behavior by requiring a provider early (like other endpoints) and calling the shared helper, keeping the Cohere fallback as an explicit/single-purpose override if needed.
 let base = match pk_entry.value.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => {
// Derive a sensible default base from the provider.
model
.provider
.and_then(default_base_for_provider)
.unwrap_or_else(|| "https://api.cohere.ai".to_string())
}
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +32 to +48
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})
}

let model_arc = Arc::new(model.clone());
let ctx = BridgeContext::new(request_id, model_arc);
let pk_arc = Arc::new(pk_entry.value.clone());
Comment on lines +121 to 134
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.model
.to_lowercase()
.starts_with(&format!("{provider_lower}/"))
.provider
.map(|p| p.as_str().eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
})?;
moonming added 4 commits May 7, 2026 14:08
Realigns the standalone Model schema with the AISIX-Cloud control
plane's normalised shape — the projection cp-api has been waiting
for since PRD-09b §6 (the comment in mustMarshalModelKV calls this
out as "Phase 2 swaps to {model, provider_key_id} with DP-side
join, but requires Model.provider_config refactor across 26 DP
files which is a separate PR" — that's this PR).
Old shape (pre-#95 + this PR):
{ name, model: "<provider>/<id>", provider_config: { api_key, api_base } }
New shape:
{ display_name, provider, model_name, provider_key_id }
Where provider_key_id references a ProviderKey row (introduced as a
top-level resource in #95) carrying secret + api_base. Routing
models keep the same `routing` block but drop the upstream-config
triple — the router resolves a target Model and dispatches against
THAT model's provider_key_id.
Why
- One ProviderKey, many Models. Rotating the upstream secret used
to require rewriting every Model row that embedded it; now it's
a single PUT against the ProviderKey.
- AISIX-Cloud parity. cp-api already has a `ProviderKey` table;
managed-mode DPs need this shape to consume what cp-api projects
into kine.
- Snapshot-table integrity. The DP can validate at load time that
every Model.provider_key_id resolves to a ProviderKey in the same
snapshot, instead of carrying inline secrets it can't cross-check.
Changes by area
aisix-core
- Model: replaced { name, model, provider_config } with
{ display_name, provider: Option<Provider>, model_name:
Option<String>, provider_key_id: Option<String> }. Routing models
set `routing` and leave the upstream triple as None.
- Removed ProviderConfig struct entirely.
- JSON Schema: oneOf encodes the direct-vs-routing XOR
(direct ⇒ all three of provider/model_name/provider_key_id
required; routing ⇒ all three forbidden).
- Resource::name() now returns &display_name; ApiKey.allowed_models
matches against the same field (already did, just renamed).
aisix-gateway
- BridgeContext gains `provider_key: Arc<ProviderKey>`. Constructor
signature is now `new(request_id, model, provider_key)`.
aisix-provider-{openai,anthropic,gemini,deepseek}
- Bridge helpers (resolve_base / api_key / upstream_model) take
`&BridgeContext` and read from ctx.provider_key + ctx.model
rather than the now-gone provider_config.
aisix-proxy
- New `dispatch.rs` resolves both Model and ProviderKey from the
snapshot before each per-endpoint handler builds BridgeContext.
- Every endpoint (chat / completions / embeddings / messages /
responses / rerank / images / audio / passthrough) updated to
use the new resolver — no more inline `model.provider_config.api_key`.
- 422 with a clear error envelope when a Model references a
provider_key_id that isn't in the snapshot.
Tests + fixtures
- Every fixture across the workspace updated to the new JSON shape
(~30 files: aisix-admin, aisix-cache, aisix-ratelimit,
aisix-proxy, aisix-gateway, aisix-server, aisix-guardrails,
aisix-etcd).
Verified
- `cargo fmt --all --check` clean
- `cargo clippy --workspace --tests -- -D warnings` clean
- `cargo test --workspace` green (520+ tests, 0 failures)
Cross-repo follow-up
- AISIX-Cloud's `mustMarshalModelKV` (internal/cpapi/resources/handlers.go)
needs to switch from writing the inline `provider_config` shape to
the new `{display_name, provider, model_name, provider_key_id}`
shape. That's tracked separately and lands in AISIX-Cloud.
The Phase B Model restructure commit landed the lib changes but the
test fixtures in crates/aisix-admin/tests/etcd_integration.rs and
tests/e2e/src/cases/smoke.test.ts still posted the old
{name, model:"openai/...", provider_config:{...}} shape. Both surfaces
fail in CI with the schema's
"Additional properties are not allowed" rejection.
- etcd_integration.rs: models_round_trip_through_real_etcd and
loader_picks_up_every_admin_write switched to {display_name,
provider, model_name, provider_key_id}
- smoke.test.ts: now posts a ProviderKey first, then references its
id from the Model — matches the production flow the dashboard
drives. Adds AdminClient.createProviderKey for the test harness.
CopilotAI review requested due to automatic review settings May 7, 2026 06:09
@moonming
moonmingforce-pushed the feat/model-provider-key-ref branch from 7d1412d to fa795d5CompareMay 7, 2026 06:09

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +79 to +90
/// The upstream API key — `provider_key.secret`. Empty string is
/// treated as a config error (ProviderKey rows shouldn't be empty,
/// but a hand-edited kine row could surface one).
pub(crate) fn require_secret<'a>(
provider_key: &'a ProviderKey,
model: &Model,
) -> Result<&'a str, ProxyError> {
if provider_key.secret.is_empty() {
return Err(ProxyError::InvalidRequest(format!(
"model {:?} provider_key has empty secret",
model.display_name
)));
Comment on lines +129 to 130
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");
Comment on lines +81 to 85
fn resolve_base(ctx: &BridgeContext) -> String {
match ctx.provider_key.api_base.as_deref() {
Some(b) if !b.trim().is_empty() => b.trim_end_matches('/').to_string(),
_ => OPENAI_DEFAULT_BASE.to_string(),
}
Comment on lines +550 to +552
Err(_) => {
last_err = Some(BridgeError::Config(
"model references unknown provider_key_id".into(),
moonming added 2 commits May 7, 2026 14:21
PR #100 (cross-provider /v1/messages — Anthropic protocol over
non-Anthropic upstreams) landed on main with the pre-Phase-B Model
API: model.provider() (method call), gemini_model(name, api_base)
helpers, etc. After rebasing Phase B on top of #100, the Anthropic
matrix tests + cross_provider_dispatch all stop compiling.
This commit ports the survivors:
- cross_provider_dispatch: switched to model.provider field access,
picks up provider_key via dispatch::resolve_provider_key, threads
it through BridgeContext::new(req_id, model, pk).
- gemini_model / deepseek_model / anthropic_model_entry test helpers
drop their api_base parameter — Phase B moves api_base onto
ProviderKey, and the matrix harness now builds a fresh PK with
the wiremock URI on every test.
- Three test sites that still passed an extra api_base argument
updated to the single-arg helper signature.
The supervisor's `apply_put`, `apply_delete`, and `clone_snapshot`
helpers only handled `models` + `api_keys` — Phase B's ProviderKey
and #97's Guardrail / CachePolicy / ObservabilityExporter were
silently no-ops. Admin writes for those four resources landed in
etcd fine, but the watch event got dropped and the proxy snapshot
never updated, so dispatch saw a Model whose `provider_key_id`
pointed at thin air. Smoke test #102 hit this:
chat returned 500: bridge is misconfigured: model references
unknown provider_key_id
Fix is mechanical: extend the for-loops in apply_put + clone_snapshot
and the match arms in apply_delete to cover every ResourceTable.
Add `apply_put_propagates_every_resource_kind` + the matching
delete test as forcing functions — any future resource type added
to AisixSnapshot fails this test until the supervisor is updated.
Verified
- cargo fmt --all --check clean
- cargo clippy --workspace --tests -- -D warnings clean
- cargo test --workspace — 548 passed, 0 failed (was 546 + 2 new)
CopilotAI review requested due to automatic review settings May 7, 2026 06:35
The smoke test's `chat completion forwards to mock upstream` case
intermittently fails on CI with `unknown provider_key_id` even though
`a Model + ApiKey written via Admin API are visible to /v1/models`
passes immediately before. The fixed-time `waitConfigPropagation()`
times out in 500ms; on slower CI runners only the Model row makes it
into the snapshot inside that window, while the ProviderKey row the
Model references arrives a beat later — long enough for the chat call
to look up `provider_key_id` and miss.
waitConfigPropagation now accepts an optional `condition` callback
that polls a positive readiness probe on a 50ms cadence with a 5s
deadline. The smoke test uses two such probes:
- After the Admin writes, poll /v1/models for the Model id (covers the
Model row's propagation as before).
- Before the chat assertion, poll the chat path itself, retrying as
long as the response carries the `unknown provider_key_id` config
error. That's the only signal that captures the *complete* snapshot
state (Model + ProviderKey + ApiKey), since the proxy doesn't
expose ProviderKey directly.
The upstream-was-hit assertion still passes because both probe and
the real call land on `/v1/chat/completions`.
Local repro stays green; CI now has 5s of headroom for the second-
event race instead of the old 0ms past the fixed sleep.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +47
pub(crate) fn resolve_provider_key(
snapshot: &AisixSnapshot,
model: &Model,
) -> Result<Arc<ResourceEntry<ProviderKey>>, ProxyError> {
let pk_id = model.provider_key_id.as_deref().ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} has no provider_key_id (routing models can't be dispatched directly)",
model.display_name
))
})?;
snapshot.provider_keys.get_by_id(pk_id).ok_or_else(|| {
ProxyError::InvalidRequest(format!(
"model {:?} references unknown provider_key_id {pk_id:?}",
model.display_name
))
})

let provider = model.provider().ok_or_else(|| {
let provider = model.provider.ok_or_else(|| {
ProxyError::InvalidRequest(format!("model `{model_name}` has no provider prefix"))
Comment on lines +129 to 131
let base = crate::dispatch::resolve_base_url(Provider::Openai, &pk_entry.value);
let url = format!("{base}/v1/responses");

Comment on lines +292 to 293
let base = crate::dispatch::resolve_base_url(provider, &pk_entry.value);
let url = format!("{base}{upstream_path}");
@moonming
moonming merged commit 86b3f88 into mainMay 7, 2026
7 checks passed
@jarvis9443
jarvis9443 deleted the feat/model-provider-key-ref branch June 25, 2026 06:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming