diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 3fe215c27a..4cca047423 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -422,6 +422,37 @@ jobs: - name: Run Azure OpenAI Python SDK tests run: uv run tests/integration/sdk/openai/test_azure_chat_completions.py -s + models-sdk: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Rust + uses: praxis-proxy/conventions/.github/actions/setup-rust@c5837c3fdd35066b05d337594fabe9d641d08df2 # v0.3.0 + with: + cache-suffix: models-sdk-cargo + + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + enable-cache: true + cache-dependency-glob: tests/integration/sdk/openai/test_openai_models.py + + - name: Build Praxis + run: cargo build -p praxis-ai-proxy --features basic-auth-filter,model-catalog-filter + + - name: Run OpenAI models Python SDK tests + run: uv run tests/integration/sdk/openai/test_openai_models.py -s + # ---------------------------------------------------------------------------- # Responses/agentic feature-boundary checks # diff --git a/Makefile b/Makefile index 391886fa5e..5aab876324 100644 --- a/Makefile +++ b/Makefile @@ -28,8 +28,8 @@ LINT_FMT_CARGO = PATH="$$(dirname "$$(rustup which --toolchain nightly cargo)"): # Experimental filter features are package-specific and off by default. # Basic Auth is exposed by praxis-ai-proxy and forwarded by the integration-test # crate; it is not a praxis-ai-filters feature. -FILTER_EXPERIMENTAL_FEATURES := azure-ad-filter,gcp-adc-filter,http-callout-filter,token-rate-limit-filter,token-ceiling-filter,inflight-tracker-filter,vertex-anthropic-filter -INTEGRATION_EXPERIMENTAL_FEATURES := azure-ad-filter,basic-auth-filter,gcp-adc-filter,http-callout-filter,token-rate-limit-filter,token-ceiling-filter,inflight-tracker-filter,vertex-anthropic-filter +FILTER_EXPERIMENTAL_FEATURES := azure-ad-filter,gcp-adc-filter,http-callout-filter,token-rate-limit-filter,token-ceiling-filter,inflight-tracker-filter,model-catalog-filter,vertex-anthropic-filter +INTEGRATION_EXPERIMENTAL_FEATURES := azure-ad-filter,basic-auth-filter,gcp-adc-filter,http-callout-filter,token-rate-limit-filter,token-ceiling-filter,inflight-tracker-filter,model-catalog-filter,vertex-anthropic-filter # Experimental praxis-ai-apis features, whose unit tests live in that crate. APIS_EXPERIMENTAL_FEATURES := vertex-anthropic # Release and container builds exclude experimental Responses and agents. @@ -272,7 +272,7 @@ test-integration-experimental: PRAXIS_AI_BIN=$(abspath $(or $(CARGO_TARGET_DIR),target)/debug/praxis-ai) \ cargo test -p praxis-tests-integration --features openai-all,store-all,$(INTEGRATION_EXPERIMENTAL_FEATURES) --test suite \ -- examples::azure_ad examples::gcp_adc examples::lakera_guard examples::token_rate_limit examples::token_ceiling examples::inflight_tracking \ - examples::vertex_anthropic \ + examples::model_catalog examples::vertex_anthropic \ $(if $(V),--nocapture) test-inference-fixtures: test-inference-fixtures-core test-inference-xtask @@ -371,7 +371,7 @@ lint: lint-clippy lint-xtask lint-clippy: setup-lint-toolchains $(LINT_CARGO) clippy --workspace --all-targets -- -D warnings $(LINT_CARGO) clippy --workspace --all-targets \ - --features $(STORE_ALL_WORKSPACE_FEATURES),xtask/openai-all,praxis-ai-proxy/azure-ad-filter,praxis-ai-proxy/basic-auth-filter,praxis-ai-proxy/gcp-adc-filter,praxis-ai-proxy/http-callout-filter,praxis-ai-proxy/token-rate-limit-filter,praxis-ai-proxy/token-ceiling-filter,praxis-ai-proxy/inflight-tracker-filter,praxis-ai-proxy/vertex-anthropic-filter,praxis-tests-integration/azure-ad-filter,praxis-tests-integration/basic-auth-filter,praxis-tests-integration/gcp-adc-filter,praxis-tests-integration/http-callout-filter,praxis-tests-integration/token-rate-limit-filter,praxis-tests-integration/token-ceiling-filter,praxis-tests-integration/inflight-tracker-filter,praxis-tests-integration/vertex-anthropic-filter \ + --features $(STORE_ALL_WORKSPACE_FEATURES),xtask/openai-all,praxis-ai-proxy/azure-ad-filter,praxis-ai-proxy/basic-auth-filter,praxis-ai-proxy/gcp-adc-filter,praxis-ai-proxy/http-callout-filter,praxis-ai-proxy/token-rate-limit-filter,praxis-ai-proxy/token-ceiling-filter,praxis-ai-proxy/inflight-tracker-filter,praxis-ai-proxy/model-catalog-filter,praxis-ai-proxy/vertex-anthropic-filter,praxis-tests-integration/azure-ad-filter,praxis-tests-integration/basic-auth-filter,praxis-tests-integration/gcp-adc-filter,praxis-tests-integration/http-callout-filter,praxis-tests-integration/token-rate-limit-filter,praxis-tests-integration/token-ceiling-filter,praxis-tests-integration/inflight-tracker-filter,praxis-tests-integration/model-catalog-filter,praxis-tests-integration/vertex-anthropic-filter \ -- -D warnings $(MAKE) lint-lean $(MAKE) check-dep-budget diff --git a/docs/filters/model_catalog.md b/docs/filters/model_catalog.md new file mode 100644 index 0000000000..2ac606a2c0 --- /dev/null +++ b/docs/filters/model_catalog.md @@ -0,0 +1,31 @@ + + + +# `model_catalog` + +Serves an OpenAI-compatible `GET /v1/models` catalog filtered per principal. + +## Configuration Notes + +The proxy answers list (`GET {list_path}`) and lookup (`GET {list_path}/{id}`) requests itself from configured entries; nothing is forwarded upstream. Each entry may restrict visibility to authenticated subjects, roles, or teams. Hidden and unknown models both return the same `404 model_not_found`, so callers cannot probe for models they cannot see. Catalog metadata (provider, version, capabilities, cost tier) is operator-declared and appears under a `praxis` key that OpenAI clients ignore. Other paths pass through untouched. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `list_path` | string | no | Path serving the model list. `{list_path}/{id}` serves one model. | +| `created` | integer | no | Unix timestamp reported as `created` when an entry omits its own. | +| `default_visibility` | `public` \| `authenticated` | no | Who may see entries that have no `visible_to` rule. | +| `models` | object[] | yes | Catalog entries in listing order. IDs should be the client-facing model IDs that inference routing accepts. | +| `models[].id` | string | yes | Client-facing model ID. | +| `models[].owned_by` | string | no | OpenAI `owned_by` value. Defaults to `provider`, then `praxis`. | +| `models[].created` | integer | no | Unix timestamp reported as `created`. Defaults to the top-level value. | +| `models[].provider` | string | no | Provider serving the model, reported in catalog metadata. | +| `models[].version` | string | no | Model version, reported in catalog metadata. | +| `models[].capabilities` | (`chat_completions` \| `completions` \| `responses` \| `messages` \| `embeddings` \| `audio_speech` \| `audio_transcriptions` \| `images` \| `realtime`)[] | no | Operator-declared API capabilities. These are not verified upstream. | +| `models[].cost_tier` | string | no | Coarse cost tier label, reported in catalog metadata. | +| `models[].aliases` | string[] | no | Additional IDs listed as their own objects with the same visibility. | +| `models[].visible_to` | object | no | Restrict visibility to matching principals. Absent uses `default_visibility`. | +| `models[].visible_to.subjects` | string[] | no | Authenticated subject IDs. | +| `models[].visible_to.roles` | string[] | no | Authenticated roles. | +| `models[].visible_to.teams` | string[] | no | Authenticated teams. | diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 797e1a518d..8def754bab 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -101,6 +101,12 @@ see the [Praxis core filter reference][core-ref]. |--------|-------------| | [`http_callout`](http_callout.md) | Calls an external HTTP service during request processing and feeds its response into branch-chain evaluation. | +### Catalog + +| Filter | Description | +|--------|-------------| +| [`model_catalog`](model_catalog.md) | Serves an OpenAI-compatible `GET /v1/models` catalog filtered per principal. | + ### GCP | Filter | Description | diff --git a/examples/README.md b/examples/README.md index a627d4f0fb..25769ff326 100644 --- a/examples/README.md +++ b/examples/README.md @@ -59,6 +59,7 @@ for how these are authored and regenerated. | [llmisvc-model-provider-resolver.yaml](configs/llmisvc-model-provider-resolver.yaml) | Rewrites publisher-ID body `model` values to the short model name for LLMISvc / KServe routing; the routing header (default `X-Model`) is left unchanged so routing can still use the publisher ID | | [mcp-classifier-routing.yaml](configs/mcp-classifier-routing.yaml) | Routes MCP requests by body-derived method and tool name | | [mcp-stateless-broker.yaml](configs/mcp-stateless-broker.yaml) | Configurable stateless MCP broker using the final MCP 2026-07-28 stateless profile | +| [model-catalog.yaml](configs/model-catalog.yaml) | Serve an OpenAI-compatible GET /v1/models listing from the proxy, filtered by the authenticated caller. basic_auth publishes the verified subject, and model_catalog lists only the entries that subject may see | | [model-to-header-routing.yaml](configs/model-to-header-routing.yaml) | Routes LLM API requests to different backends based on the "model" field in the JSON request body | | [model-to-header-trust-existing.yaml](configs/model-to-header-trust-existing.yaml) | Routes on a model header that a trusted hop in front of Praxis has already set, skipping the filter entirely when the header is present | | [model-to-provider.yaml](configs/model-to-provider.yaml) | Map a stable public model ID to an existing upstream cluster and target model | diff --git a/examples/configs/model-catalog.yaml b/examples/configs/model-catalog.yaml new file mode 100644 index 0000000000..ae5749f781 --- /dev/null +++ b/examples/configs/model-catalog.yaml @@ -0,0 +1,75 @@ +# Principal-aware model catalog +# +# Serve an OpenAI-compatible GET /v1/models listing from the proxy, filtered by +# the authenticated caller. basic_auth publishes the verified subject, and +# model_catalog lists only the entries that subject may see. A hidden model +# returns the same 404 as an unknown one. Catalog IDs match the public IDs that +# model_to_provider routes, aliases included. Capabilities and cost tiers are +# operator-declared metadata, not verified upstream. The catalog controls +# discovery only; enforcing model access on inference requests is a separate +# policy concern. +# +# Usage: +# cargo run -p praxis-ai-proxy --features basic-auth-filter,model-catalog-filter -- -c examples/configs/model-catalog.yaml +# curl -u viewer:example-password http://localhost:8080/v1/models +# curl -u ml-engineer:example-password http://localhost:8080/v1/models/claude-sonnet-4-5 + +listeners: + - name: default + address: "127.0.0.1:8080" + filter_chains: + - main + +filter_chains: + - name: main + filters: + - filter: basic_auth + strip_authorization: true + credentials: + - username: viewer + password: "example-password" + - username: ml-engineer + password: "example-password" + + - filter: model_catalog + created: 1735689600 + models: + - id: gpt-4o + provider: openai + capabilities: [chat_completions, responses] + cost_tier: standard + aliases: [gpt4o] + - id: claude-sonnet-4-5 + owned_by: anthropic + provider: vertex + capabilities: [messages] + cost_tier: premium + visible_to: + subjects: [ml-engineer] + + - filter: model_to_provider + models: + - model: gpt-4o + upstream: openai + target_model: gpt-4o + paths: [/v1/chat/completions, /v1/responses] + - model: gpt4o + upstream: openai + target_model: gpt-4o + paths: [/v1/chat/completions, /v1/responses] + - model: claude-sonnet-4-5 + upstream: vertex + target_model: claude-sonnet-4-5@20250929 + paths: [/v1/messages] + + - filter: load_balancer + clusters: + - name: openai + endpoints: + - "127.0.0.1:3000" + - name: vertex + endpoints: + - "127.0.0.1:3001" + +insecure_options: + allow_private_endpoints: true diff --git a/filters/Cargo.toml b/filters/Cargo.toml index 1e3b421d25..80377ed8df 100644 --- a/filters/Cargo.toml +++ b/filters/Cargo.toml @@ -76,6 +76,9 @@ token-ceiling-filter = ["experimental", "dep:tiktoken-rs"] # backing store is still an open question, so the filter itself stays gated # until both land. Activates the `experimental` marker. inflight-tracker-filter = ["experimental"] +# Experimental: the model_catalog filter, serving a principal-filtered +# GET /v1/models from static config (ai#1067). Activates the `experimental` marker. +model-catalog-filter = ["experimental"] # Marker feature activated transitively by any experimental feature, mirroring # praxis core's server crate. Lets consumers gate on "anything experimental". experimental = ["praxis-ai-apis/experimental"] diff --git a/filters/src/catalog/config.rs b/filters/src/catalog/config.rs new file mode 100644 index 0000000000..0ed645f166 --- /dev/null +++ b/filters/src/catalog/config.rs @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Deserialized YAML configuration for the model catalog filter. + +use std::collections::HashSet; + +use praxis_filter::FilterError; +use serde::{Deserialize, Serialize}; + +/// Default path that serves the OpenAI-compatible model listing. +pub(super) const DEFAULT_LIST_PATH: &str = "/v1/models"; + +/// YAML config for the `model_catalog` filter. +/// +/// ```yaml +/// filter: model_catalog +/// list_path: /v1/models # default +/// default_visibility: public # public | authenticated +/// models: +/// - id: gpt-4o +/// owned_by: openai +/// provider: openai +/// version: "2024-08-06" +/// capabilities: [chat_completions, responses] +/// cost_tier: standard +/// aliases: [gpt4o] +/// - id: claude-sonnet-4-5 +/// owned_by: anthropic +/// provider: vertex +/// capabilities: [messages] +/// cost_tier: premium +/// visible_to: +/// subjects: [alice] +/// roles: [ml-eng] +/// teams: [research] +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ModelCatalogConfig { + /// Path serving the model list. `{list_path}/{id}` serves one model. + #[serde(default = "default_list_path")] + pub list_path: String, + + /// Unix timestamp reported as `created` when an entry omits its own. + #[serde(default)] + pub created: u64, + + /// Who may see entries that have no `visible_to` rule. + #[serde(default)] + pub default_visibility: DefaultVisibility, + + /// Catalog entries in listing order. IDs should be the client-facing model + /// IDs that inference routing accepts. + pub models: Vec, +} + +/// One model advertised by the catalog. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ModelEntryConfig { + /// Client-facing model ID. + pub id: String, + + /// OpenAI `owned_by` value. Defaults to `provider`, then `praxis`. + #[serde(default)] + pub owned_by: Option, + + /// Unix timestamp reported as `created`. Defaults to the top-level value. + #[serde(default)] + pub created: Option, + + /// Provider serving the model, reported in catalog metadata. + #[serde(default)] + pub provider: Option, + + /// Model version, reported in catalog metadata. + #[serde(default)] + pub version: Option, + + /// Operator-declared API capabilities. These are not verified upstream. + #[serde(default)] + pub capabilities: Vec, + + /// Coarse cost tier label, reported in catalog metadata. + #[serde(default)] + pub cost_tier: Option, + + /// Additional IDs listed as their own objects with the same visibility. + #[serde(default)] + pub aliases: Vec, + + /// Restrict visibility to matching principals. Absent uses + /// `default_visibility`. + #[serde(default)] + pub visible_to: Option, +} + +/// Principals allowed to see an entry. A caller matching any list sees it. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct VisibleToConfig { + /// Authenticated subject IDs. + #[serde(default)] + pub subjects: Vec, + + /// Authenticated roles. + #[serde(default)] + pub roles: Vec, + + /// Authenticated teams. + #[serde(default)] + pub teams: Vec, +} + +impl VisibleToConfig { + /// Whether no principal could ever match this rule. + fn is_empty(&self) -> bool { + self.subjects.is_empty() && self.roles.is_empty() && self.teams.is_empty() + } +} + +/// Visibility of entries without a `visible_to` rule. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum DefaultVisibility { + /// Visible to every caller. + #[default] + Public, + /// Visible only to callers with an authenticated identity. + Authenticated, +} + +/// API capability an operator declares for a model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum Capability { + /// `POST /v1/chat/completions`. + ChatCompletions, + /// `POST /v1/completions`. + Completions, + /// `POST /v1/responses`. + Responses, + /// `POST /v1/messages`. + Messages, + /// `POST /v1/embeddings`. + Embeddings, + /// `POST /v1/audio/speech`. + AudioSpeech, + /// `POST /v1/audio/transcriptions`. + AudioTranscriptions, + /// `POST /v1/images/generations`. + Images, + /// Realtime sessions. + Realtime, +} + +/// Validate config at construction time. +pub(super) fn validate_config(cfg: &ModelCatalogConfig) -> Result<(), FilterError> { + if !cfg.list_path.starts_with('/') { + return Err("model_catalog: list_path must start with '/'".into()); + } + if cfg.list_path.len() > 1 && cfg.list_path.ends_with('/') { + return Err("model_catalog: list_path must not end with '/'".into()); + } + if cfg.models.is_empty() { + return Err("model_catalog: models must not be empty".into()); + } + + let mut seen = HashSet::new(); + for entry in &cfg.models { + validate_entry(entry, &mut seen)?; + } + Ok(()) +} + +/// Validate one entry, recording its IDs in `seen` to reject duplicates. +fn validate_entry<'a>(entry: &'a ModelEntryConfig, seen: &mut HashSet<&'a str>) -> Result<(), FilterError> { + for id in std::iter::once(&entry.id).chain(&entry.aliases) { + validate_id(id)?; + if !seen.insert(id.as_str()) { + return Err(format!("model_catalog: duplicate model id '{id}'").into()); + } + } + if entry.visible_to.as_ref().is_some_and(VisibleToConfig::is_empty) { + return Err(format!( + "model_catalog: visible_to for '{}' must list at least one subject, role, or team", + entry.id + ) + .into()); + } + for (field, value) in [ + ("owned_by", &entry.owned_by), + ("provider", &entry.provider), + ("version", &entry.version), + ("cost_tier", &entry.cost_tier), + ] { + if value.as_deref().is_some_and(str::is_empty) { + return Err(format!("model_catalog: {field} for '{}' must not be empty", entry.id).into()); + } + } + Ok(()) +} + +/// Reject IDs that cannot round-trip through `{list_path}/{id}`. +fn validate_id(id: &str) -> Result<(), FilterError> { + if id.is_empty() { + return Err("model_catalog: model ids must not be empty".into()); + } + if id + .chars() + .any(|c| c.is_control() || c.is_whitespace() || c == '?' || c == '#') + { + return Err(format!("model_catalog: model id '{id}' contains a character that cannot appear in a path").into()); + } + Ok(()) +} + +/// Serde default for `list_path`. +fn default_list_path() -> String { + DEFAULT_LIST_PATH.to_owned() +} diff --git a/filters/src/catalog/mod.rs b/filters/src/catalog/mod.rs new file mode 100644 index 0000000000..803d230e62 --- /dev/null +++ b/filters/src/catalog/mod.rs @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Principal-aware model catalog served from static configuration. + +mod config; +mod visibility; + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; + +use async_trait::async_trait; +use bytes::{BufMut as _, Bytes, BytesMut}; +use http::{HeaderMap, HeaderValue, Method, header}; +use percent_encoding::percent_decode_str; +use praxis_filter::{ + AuthenticatedIdentity, FilterAction, FilterError, HttpFilter, HttpFilterContext, TerminalResponse, +}; +use serde::Serialize; + +use self::{ + config::{Capability, ModelCatalogConfig, ModelEntryConfig, validate_config}, + visibility::{PrincipalView, Visibility}, +}; + +/// `owned_by` reported when an entry names neither `owned_by` nor `provider`. +const DEFAULT_OWNED_BY: &str = "praxis"; + +// ----------------------------------------------------------------------------- +// ModelCatalogFilter +// ----------------------------------------------------------------------------- + +/// Serves an OpenAI-compatible `GET /v1/models` catalog filtered per principal. +/// +/// The proxy answers list (`GET {list_path}`) and lookup +/// (`GET {list_path}/{id}`) requests itself from configured entries; nothing is +/// forwarded upstream. Each entry may restrict visibility to authenticated +/// subjects, roles, or teams. Hidden and unknown models both return the same +/// `404 model_not_found`, so callers cannot probe for models they cannot see. +/// Catalog metadata (provider, version, capabilities, cost tier) is +/// operator-declared and appears under a `praxis` key that OpenAI clients +/// ignore. Other paths pass through untouched. +pub struct ModelCatalogFilter { + /// Path serving the model list. + list_path: String, + /// Compiled visibility rules, indexed by [`CatalogObject::visibility`]. + visibilities: Vec, + /// Listed objects (entries and aliases) in config order. + objects: Vec, + /// Object index by model ID. + by_id: HashMap, +} + +/// One listed model object with its pre-serialized JSON. +struct CatalogObject { + /// Index of the owning entry's visibility rule. + visibility: usize, + /// Serialized OpenAI model object. + json: Bytes, +} + +impl ModelCatalogFilter { + /// Build the filter from YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] when the config is malformed or invalid. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let cfg: ModelCatalogConfig = praxis_filter::parse_filter_config("model_catalog", config)?; + validate_config(&cfg)?; + Ok(Box::new(Self::build(cfg)?)) + } + + /// Compile validated config into visibility rules and serialized objects. + fn build(cfg: ModelCatalogConfig) -> Result { + let mut visibilities = Vec::with_capacity(cfg.models.len()); + let mut objects = Vec::new(); + let mut by_id = HashMap::new(); + + for mut entry in cfg.models { + let visibility = visibilities.len(); + visibilities.push(Visibility::compile(entry.visible_to.take(), cfg.default_visibility)); + let payloads = entry_payloads(&entry, cfg.created)?; + for (id, json) in std::iter::once(entry.id).chain(entry.aliases).zip(payloads) { + by_id.insert(id, objects.len()); + objects.push(CatalogObject { visibility, json }); + } + } + + Ok(Self { + list_path: cfg.list_path, + visibilities, + objects, + by_id, + }) + } + + /// Classify a request path against the catalog routes. + fn route<'p>(&self, path: &'p str) -> Option> { + let path = path.strip_suffix('/').filter(|p| !p.is_empty()).unwrap_or(path); + let rest = path.strip_prefix(self.list_path.as_str())?; + if rest.is_empty() { + return Some(CatalogRoute::List); + } + rest.strip_prefix('/').map(CatalogRoute::Retrieve) + } + + /// Whether `principal` may see `object`. + fn visible(&self, object: &CatalogObject, principal: Option>) -> bool { + self.visibilities + .get(object.visibility) + .is_some_and(|rule| rule.allows(principal)) + } + + /// Assemble the list body from the objects visible to `principal`. + fn list_body(&self, principal: Option>) -> Bytes { + const PREFIX: &[u8] = br#"{"object":"list","data":["#; + const SUFFIX: &[u8] = b"]}"; + let visible: Vec<&CatalogObject> = self + .objects + .iter() + .filter(|object| self.visible(object, principal)) + .collect(); + let len = PREFIX.len() + SUFFIX.len() + visible.iter().map(|o| o.json.len() + 1).sum::(); + let mut body = BytesMut::with_capacity(len); + body.put_slice(PREFIX); + for (index, object) in visible.iter().enumerate() { + if index > 0 { + body.put_u8(b','); + } + body.put_slice(&object.json); + } + body.put_slice(SUFFIX); + body.freeze() + } + + /// Respond to a lookup, hiding unknown and invisible models alike. + fn retrieve(&self, raw_id: &str, principal: Option>) -> TerminalResponse { + let id = percent_decode_str(raw_id).decode_utf8_lossy(); + match self.by_id.get(id.as_ref()).and_then(|&index| self.objects.get(index)) { + Some(object) if self.visible(object, principal) => json_response(200, object.json.clone()), + _ => json_response( + 404, + error_body(&format!("The model '{id}' does not exist"), "model_not_found"), + ), + } + } +} + +#[async_trait] +impl HttpFilter for ModelCatalogFilter { + fn name(&self) -> &'static str { + "model_catalog" + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let Some(route) = self.route(ctx.request.uri.path()) else { + return Ok(FilterAction::Continue); + }; + if ctx.request.method != Method::GET { + let mut response = json_response( + 405, + error_body("Method not allowed for the models endpoint", "method_not_allowed"), + ); + response.headers.insert(header::ALLOW, HeaderValue::from_static("GET")); + return Ok(FilterAction::TerminalResponse(Box::new(response))); + } + + let principal = ctx.extensions.get::().map(PrincipalView::from); + let response = match route { + CatalogRoute::List => json_response(200, self.list_body(principal)), + CatalogRoute::Retrieve(id) => self.retrieve(id, principal), + }; + Ok(FilterAction::TerminalResponse(Box::new(response))) + } +} + +// ----------------------------------------------------------------------------- +// Serialization +// ----------------------------------------------------------------------------- + +/// A request the catalog answers. +#[derive(Debug, PartialEq, Eq)] +enum CatalogRoute<'p> { + /// `GET {list_path}`. + List, + /// `GET {list_path}/{id}` with the raw (still percent-encoded) ID. + Retrieve(&'p str), +} + +/// OpenAI model object with optional catalog metadata. +#[derive(Serialize)] +struct ModelObject<'a> { + /// Model ID. + id: &'a str, + /// Always `model`. + object: &'static str, + /// Unix creation timestamp. + created: u64, + /// Owning organization. + owned_by: &'a str, + /// Catalog metadata, omitted when empty. + #[serde(skip_serializing_if = "Option::is_none")] + praxis: Option<&'a CatalogMeta<'a>>, +} + +/// Operator-declared catalog metadata under the `praxis` key. +#[derive(Clone, Copy, Serialize)] +struct CatalogMeta<'a> { + /// Provider serving the model. + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option<&'a str>, + /// Model version. + #[serde(skip_serializing_if = "Option::is_none")] + version: Option<&'a str>, + /// Declared API capabilities. + #[serde(skip_serializing_if = "<[_]>::is_empty")] + capabilities: &'a [Capability], + /// Coarse cost tier. + #[serde(skip_serializing_if = "Option::is_none")] + cost_tier: Option<&'a str>, + /// Alias IDs of a canonical entry. + #[serde(skip_serializing_if = "<[_]>::is_empty")] + aliases: &'a [String], + /// Canonical ID of an alias object. + #[serde(skip_serializing_if = "Option::is_none")] + alias_of: Option<&'a str>, +} + +impl CatalogMeta<'_> { + /// Whether no metadata field would be serialized. + fn is_empty(&self) -> bool { + self.provider.is_none() + && self.version.is_none() + && self.capabilities.is_empty() + && self.cost_tier.is_none() + && self.aliases.is_empty() + && self.alias_of.is_none() + } +} + +/// Serialize an entry's canonical object followed by one object per alias. +fn entry_payloads(entry: &ModelEntryConfig, default_created: u64) -> Result, FilterError> { + let owned_by = entry + .owned_by + .as_deref() + .or(entry.provider.as_deref()) + .unwrap_or(DEFAULT_OWNED_BY); + let created = entry.created.unwrap_or(default_created); + let meta = CatalogMeta { + provider: entry.provider.as_deref(), + version: entry.version.as_deref(), + capabilities: &entry.capabilities, + cost_tier: entry.cost_tier.as_deref(), + aliases: &entry.aliases, + alias_of: None, + }; + + let mut payloads = Vec::with_capacity(1 + entry.aliases.len()); + payloads.push(serialize_object(&entry.id, created, owned_by, &meta)?); + let alias_meta = CatalogMeta { + aliases: &[], + alias_of: Some(&entry.id), + ..meta + }; + for alias in &entry.aliases { + payloads.push(serialize_object(alias, created, owned_by, &alias_meta)?); + } + Ok(payloads) +} + +/// Serialize one model object once, at build time. +fn serialize_object(id: &str, created: u64, owned_by: &str, meta: &CatalogMeta<'_>) -> Result { + let object = ModelObject { + id, + object: "model", + created, + owned_by, + praxis: (!meta.is_empty()).then_some(meta), + }; + serde_json::to_vec(&object) + .map(Bytes::from) + .map_err(|error| format!("model_catalog: failed to serialize model '{id}': {error}").into()) +} + +/// Build an OpenAI-style error body. +fn error_body(message: &str, code: &str) -> Bytes { + let body = serde_json::json!({ + "error": { + "message": message, + "type": "invalid_request_error", + "param": null, + "code": code, + } + }); + Bytes::from(body.to_string()) +} + +/// Build a JSON terminal response that must not be cached across principals. +fn json_response(status: u16, body: Bytes) -> TerminalResponse { + let mut headers = HeaderMap::with_capacity(2); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store")); + TerminalResponse::new(status).with_headers(headers).with_body(body) +} diff --git a/filters/src/catalog/tests.rs b/filters/src/catalog/tests.rs new file mode 100644 index 0000000000..db68fef4a1 --- /dev/null +++ b/filters/src/catalog/tests.rs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Unit tests for the model catalog filter. + +#![expect(clippy::expect_used, clippy::panic, clippy::indexing_slicing, reason = "tests")] + +use std::collections::BTreeSet; + +use http::Method; +use praxis_filter::{FilterAction, HttpFilter as _, TerminalResponse}; +use serde_json::Value; + +use super::{ + CatalogRoute, ModelCatalogFilter, + config::{DefaultVisibility, ModelCatalogConfig, validate_config}, + visibility::{PrincipalView, Visibility}, +}; +use crate::test_utils::{make_filter_context, make_request}; + +const CATALOG: &str = r#" +created: 1700000000 +models: + - id: gpt-4o + owned_by: openai + provider: openai + version: "2024-08-06" + capabilities: [chat_completions, responses] + cost_tier: standard + aliases: [gpt4o] + - id: meta-llama/Llama-3.1-8B + created: 1720000000 + - id: claude-sonnet-4-5 + provider: vertex + capabilities: [messages] + visible_to: + subjects: [alice] + roles: [ml-eng] + teams: [research] +"#; + +fn parse(yaml: &str) -> ModelCatalogConfig { + serde_yaml::from_str(yaml).expect("valid catalog yaml") +} + +fn build(yaml: &str) -> ModelCatalogFilter { + let cfg = parse(yaml); + validate_config(&cfg).expect("valid catalog config"); + ModelCatalogFilter::build(cfg).expect("catalog builds") +} + +fn validation_error(yaml: &str) -> String { + validate_config(&parse(yaml)) + .expect_err("config should be rejected") + .to_string() +} + +fn body_json(response: &TerminalResponse) -> Value { + serde_json::from_slice(response.body.as_ref().expect("response body")).expect("json body") +} + +fn listed_ids(response: &TerminalResponse) -> Vec { + let body = body_json(response); + assert_eq!(body["object"], "list"); + body["data"] + .as_array() + .expect("data array") + .iter() + .map(|model| model["id"].as_str().expect("id").to_owned()) + .collect() +} + +fn principal<'a>(subject: &'a str, roles: &'a BTreeSet, teams: &'a BTreeSet) -> PrincipalView<'a> { + PrincipalView { subject, roles, teams } +} + +fn set(values: &[&str]) -> BTreeSet { + values.iter().map(|&value| value.to_owned()).collect() +} + +async fn respond(filter: &ModelCatalogFilter, method: Method, path: &str) -> FilterAction { + let request = make_request(method, path); + let mut ctx = make_filter_context(&request); + filter.on_request(&mut ctx).await.expect("on_request") +} + +fn terminal(action: FilterAction) -> TerminalResponse { + match action { + FilterAction::TerminalResponse(response) => *response, + _ => panic!("expected a terminal response"), + } +} + +// ----------------------------------------------------------------------------- +// Routing +// ----------------------------------------------------------------------------- + +#[test] +fn route_matches_list_and_retrieve_paths() { + let filter = build(CATALOG); + assert_eq!(filter.route("/v1/models"), Some(CatalogRoute::List)); + assert_eq!(filter.route("/v1/models/"), Some(CatalogRoute::List)); + assert_eq!( + filter.route("/v1/models/gpt-4o"), + Some(CatalogRoute::Retrieve("gpt-4o")) + ); + assert_eq!( + filter.route("/v1/models/meta-llama/Llama-3.1-8B"), + Some(CatalogRoute::Retrieve("meta-llama/Llama-3.1-8B")) + ); + assert_eq!(filter.route("/v1/modelsx"), None); + assert_eq!(filter.route("/v1/chat/completions"), None); +} + +#[test] +fn route_honours_custom_list_path() { + let filter = build("list_path: /catalog/models\nmodels: [{id: m}]"); + assert_eq!(filter.route("/catalog/models"), Some(CatalogRoute::List)); + assert_eq!(filter.route("/v1/models"), None); +} + +#[tokio::test] +async fn non_catalog_paths_continue() { + let filter = build(CATALOG); + assert!(matches!( + respond(&filter, Method::POST, "/v1/chat/completions").await, + FilterAction::Continue + )); +} + +#[tokio::test] +async fn non_get_methods_are_rejected_with_405() { + let filter = build(CATALOG); + let response = terminal(respond(&filter, Method::POST, "/v1/models").await); + assert_eq!(response.status, 405); + assert_eq!(response.headers["allow"], "GET"); + assert_eq!(body_json(&response)["error"]["code"], "method_not_allowed"); +} + +// ----------------------------------------------------------------------------- +// Listing +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn anonymous_list_shows_only_public_models() { + let filter = build(CATALOG); + let response = terminal(respond(&filter, Method::GET, "/v1/models").await); + assert_eq!(response.status, 200); + assert_eq!(response.headers["content-type"], "application/json"); + assert_eq!(response.headers["cache-control"], "private, no-store"); + assert_eq!(listed_ids(&response), ["gpt-4o", "gpt4o", "meta-llama/Llama-3.1-8B"]); +} + +#[test] +fn list_includes_restricted_models_for_matching_principal() { + let filter = build(CATALOG); + let empty = set(&[]); + let body = filter.list_body(Some(principal("alice", &empty, &empty))); + let response = TerminalResponse::new(200).with_body(body); + assert_eq!( + listed_ids(&response), + ["gpt-4o", "gpt4o", "meta-llama/Llama-3.1-8B", "claude-sonnet-4-5"] + ); +} + +#[test] +fn list_serializes_openai_shape_and_catalog_metadata() { + let filter = build(CATALOG); + let response = TerminalResponse::new(200).with_body(filter.list_body(None)); + let body = body_json(&response); + + let gpt = &body["data"][0]; + assert_eq!(gpt["object"], "model"); + assert_eq!(gpt["created"], 1_700_000_000); + assert_eq!(gpt["owned_by"], "openai"); + assert_eq!(gpt["praxis"]["provider"], "openai"); + assert_eq!(gpt["praxis"]["version"], "2024-08-06"); + assert_eq!( + gpt["praxis"]["capabilities"], + serde_json::json!(["chat_completions", "responses"]) + ); + assert_eq!(gpt["praxis"]["cost_tier"], "standard"); + assert_eq!(gpt["praxis"]["aliases"], serde_json::json!(["gpt4o"])); + + let alias = &body["data"][1]; + assert_eq!(alias["praxis"]["alias_of"], "gpt-4o"); + assert!(alias["praxis"].get("aliases").is_none(), "aliases do not list aliases"); + + let llama = &body["data"][2]; + assert_eq!(llama["created"], 1_720_000_000); + assert_eq!(llama["owned_by"], "praxis"); + assert!( + llama.get("praxis").is_none(), + "entries without metadata omit the praxis key" + ); +} + +#[test] +fn owned_by_defaults_to_provider() { + let filter = build(CATALOG); + let empty = set(&[]); + let response = TerminalResponse::new(200).with_body(filter.list_body(Some(principal("alice", &empty, &empty)))); + assert_eq!(body_json(&response)["data"][3]["owned_by"], "vertex"); +} + +// ----------------------------------------------------------------------------- +// Retrieval +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn retrieve_returns_visible_model_and_decodes_path() { + let filter = build(CATALOG); + let response = terminal(respond(&filter, Method::GET, "/v1/models/gpt4o").await); + assert_eq!(response.status, 200); + assert_eq!(body_json(&response)["id"], "gpt4o"); + + let encoded = terminal(respond(&filter, Method::GET, "/v1/models/meta-llama%2FLlama-3.1-8B").await); + assert_eq!(encoded.status, 200); + assert_eq!(body_json(&encoded)["id"], "meta-llama/Llama-3.1-8B"); +} + +#[tokio::test] +async fn hidden_and_unknown_models_return_identical_404() { + let filter = build(CATALOG); + let hidden = terminal(respond(&filter, Method::GET, "/v1/models/claude-sonnet-4-5").await); + let unknown = terminal(respond(&filter, Method::GET, "/v1/models/no-such-model").await); + assert_eq!(hidden.status, 404); + assert_eq!(unknown.status, 404); + + let hidden = body_json(&hidden); + let unknown = body_json(&unknown); + assert_eq!(hidden["error"]["code"], "model_not_found"); + assert_eq!(hidden["error"]["type"], unknown["error"]["type"]); + assert_eq!( + hidden["error"]["message"], + "The model 'claude-sonnet-4-5' does not exist" + ); +} + +#[test] +fn retrieve_restricted_model_for_matching_principal() { + let filter = build(CATALOG); + let empty = set(&[]); + let response = filter.retrieve("claude-sonnet-4-5", Some(principal("alice", &empty, &empty))); + assert_eq!(response.status, 200); +} + +// ----------------------------------------------------------------------------- +// Visibility +// ----------------------------------------------------------------------------- + +#[test] +fn restricted_visibility_matches_subject_role_or_team() { + let rule = Visibility::compile( + parse("models: [{id: m, visible_to: {subjects: [alice], roles: [ml-eng], teams: [research]}}]") + .models + .pop() + .and_then(|entry| entry.visible_to), + DefaultVisibility::Public, + ); + let empty = set(&[]); + let role = set(&["ml-eng"]); + let team = set(&["research"]); + let other = set(&["sales"]); + + assert!(rule.allows(Some(principal("alice", &empty, &empty)))); + assert!(rule.allows(Some(principal("bob", &role, &empty)))); + assert!(rule.allows(Some(principal("bob", &empty, &team)))); + assert!(!rule.allows(Some(principal("bob", &other, &other)))); + assert!(!rule.allows(None)); +} + +#[test] +fn authenticated_default_visibility_requires_identity() { + let filter = build("default_visibility: authenticated\nmodels: [{id: m}]"); + let empty = set(&[]); + let anonymous = TerminalResponse::new(200).with_body(filter.list_body(None)); + let signed_in = TerminalResponse::new(200).with_body(filter.list_body(Some(principal("bob", &empty, &empty)))); + assert!(listed_ids(&anonymous).is_empty()); + assert_eq!(listed_ids(&signed_in), ["m"]); +} + +// ----------------------------------------------------------------------------- +// Configuration +// ----------------------------------------------------------------------------- + +#[test] +fn from_config_registers_valid_catalog() { + let value: serde_yaml::Value = serde_yaml::from_str(CATALOG).expect("yaml"); + let filter = ModelCatalogFilter::from_config(&value).expect("filter builds"); + assert_eq!(filter.name(), "model_catalog"); +} + +#[test] +fn unknown_fields_are_rejected() { + let value: serde_yaml::Value = serde_yaml::from_str("models: [{id: m, colour: blue}]").expect("yaml"); + assert!(ModelCatalogFilter::from_config(&value).is_err()); +} + +#[test] +fn unknown_capability_is_rejected() { + let value: serde_yaml::Value = serde_yaml::from_str("models: [{id: m, capabilities: [telepathy]}]").expect("yaml"); + assert!(ModelCatalogFilter::from_config(&value).is_err()); +} + +#[test] +fn validation_rejects_invalid_catalogs() { + for (yaml, expected) in [ + ("models: []", "models must not be empty"), + ("list_path: v1/models\nmodels: [{id: m}]", "must start with '/'"), + ("list_path: /v1/models/\nmodels: [{id: m}]", "must not end with '/'"), + ("models: [{id: ''}]", "must not be empty"), + ("models: [{id: 'a b'}]", "cannot appear in a path"), + ("models: [{id: m}, {id: m}]", "duplicate model id 'm'"), + ("models: [{id: m, aliases: [n]}, {id: n}]", "duplicate model id 'n'"), + ("models: [{id: m, visible_to: {}}]", "must list at least one"), + ( + "models: [{id: m, cost_tier: ''}]", + "cost_tier for 'm' must not be empty", + ), + ] { + let error = validation_error(yaml); + assert!(error.contains(expected), "{yaml}: expected '{expected}' in '{error}'"); + } +} diff --git a/filters/src/catalog/visibility.rs b/filters/src/catalog/visibility.rs new file mode 100644 index 0000000000..4a04f4659c --- /dev/null +++ b/filters/src/catalog/visibility.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Principal matching for catalog entry visibility. + +use std::collections::{BTreeSet, HashSet}; + +use praxis_filter::AuthenticatedIdentity; + +use super::config::{DefaultVisibility, VisibleToConfig}; + +/// Borrowed view of the caller's authenticated identity. +/// +/// Kept separate from [`AuthenticatedIdentity`] so unit tests can drive +/// matching without the crate-private core constructor. +#[derive(Debug, Clone, Copy)] +pub(super) struct PrincipalView<'a> { + /// Authenticated subject ID. + pub subject: &'a str, + /// Authenticated roles. + pub roles: &'a BTreeSet, + /// Authenticated teams. + pub teams: &'a BTreeSet, +} + +impl<'a> From<&'a AuthenticatedIdentity> for PrincipalView<'a> { + fn from(identity: &'a AuthenticatedIdentity) -> Self { + Self { + subject: identity.subject_id(), + roles: identity.roles(), + teams: identity.teams(), + } + } +} + +/// Compiled visibility rule for one catalog entry. +#[derive(Debug)] +pub(super) enum Visibility { + /// Visible to every caller. + Public, + /// Visible to any authenticated caller. + Authenticated, + /// Visible to callers matching any listed subject, role, or team. + Restricted(Box), +} + +/// Principals allowed to see a restricted entry. +#[derive(Debug)] +pub(super) struct AllowedPrincipals { + /// Allowed subject IDs. + subjects: HashSet, + /// Allowed roles. + roles: HashSet, + /// Allowed teams. + teams: HashSet, +} + +impl Visibility { + /// Compile an entry's rule, falling back to the catalog default. + pub(super) fn compile(rule: Option, default: DefaultVisibility) -> Self { + match rule { + Some(rule) => Self::Restricted(Box::new(AllowedPrincipals { + subjects: rule.subjects.into_iter().collect(), + roles: rule.roles.into_iter().collect(), + teams: rule.teams.into_iter().collect(), + })), + None => match default { + DefaultVisibility::Public => Self::Public, + DefaultVisibility::Authenticated => Self::Authenticated, + }, + } + } + + /// Whether `principal` may see the entry. + pub(super) fn allows(&self, principal: Option>) -> bool { + match (self, principal) { + (Self::Public, _) => true, + (Self::Authenticated, principal) => principal.is_some(), + (Self::Restricted(_), None) => false, + (Self::Restricted(allowed), Some(principal)) => { + allowed.subjects.contains(principal.subject) + || principal.roles.iter().any(|role| allowed.roles.contains(role)) + || principal.teams.iter().any(|team| allowed.teams.contains(team)) + }, + } + } +} diff --git a/filters/src/lib.rs b/filters/src/lib.rs index 12eeae9834..96ec1cf18c 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -15,6 +15,8 @@ pub mod aws; pub mod azure; #[cfg(feature = "http-callout-filter")] pub mod callout; +#[cfg(feature = "model-catalog-filter")] +pub mod catalog; #[cfg(feature = "gcp-adc-filter")] pub mod gcp; pub mod guardrails; @@ -42,6 +44,8 @@ pub use aws::Sigv4SignFilter; pub use azure::AzureAdFilter; #[cfg(feature = "http-callout-filter")] pub use callout::HttpCalloutFilter; +#[cfg(feature = "model-catalog-filter")] +pub use catalog::ModelCatalogFilter; #[cfg(feature = "gcp-adc-filter")] pub use gcp::GcpAdcFilter; pub use guardrails::AiGuardrailsFilter; diff --git a/filters/src/register.rs b/filters/src/register.rs index 76893649dc..f81c62e604 100644 --- a/filters/src/register.rs +++ b/filters/src/register.rs @@ -14,6 +14,8 @@ use crate::GcpAdcFilter; use crate::HttpCalloutFilter; #[cfg(feature = "inflight-tracker-filter")] use crate::InFlightTrackerFilter; +#[cfg(feature = "model-catalog-filter")] +use crate::ModelCatalogFilter; #[cfg(feature = "aws-sigv4-filter")] use crate::Sigv4SignFilter; #[cfg(feature = "token-ceiling-filter")] @@ -242,6 +244,11 @@ fn register_inference_filters(registry: &mut FilterRegistry) { @register registry, http "llmisvc_model_provider_resolver" => LlmisvcModelProviderResolverFilter::from_config ); + #[cfg(feature = "model-catalog-filter")] + praxis_filter::register_filters!( + @register registry, + http "model_catalog" => ModelCatalogFilter::from_config + ); } /// Register token counting/usage/rate-limiting filters. @@ -913,6 +920,7 @@ provider: assert_experimental_registration(&names, "token_rate_limit", cfg!(feature = "token-rate-limit-filter")); assert_experimental_registration(&names, "token_ceiling", cfg!(feature = "token-ceiling-filter")); assert_experimental_registration(&names, "inflight_tracker", cfg!(feature = "inflight-tracker-filter")); + assert_experimental_registration(&names, "model_catalog", cfg!(feature = "model-catalog-filter")); } /// Every opt-in filter paired with whether its cargo feature is enabled. diff --git a/server/Cargo.toml b/server/Cargo.toml index ff9c689531..63c2d71950 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -73,6 +73,8 @@ token-rate-limit-filter = ["praxis-ai-filters/token-rate-limit-filter", "experim token-ceiling-filter = ["praxis-ai-filters/token-ceiling-filter", "experimental"] # Experimental: the inflight_tracker filter (see praxis-ai-filters). inflight-tracker-filter = ["praxis-ai-filters/inflight-tracker-filter", "experimental"] +# Experimental: the model_catalog filter (see praxis-ai-filters). +model-catalog-filter = ["praxis-ai-filters/model-catalog-filter", "experimental"] # Marker feature activated transitively by any experimental feature. experimental = ["praxis-ai-filters/experimental"] opentelemetry = ["praxis-ai-filters/opentelemetry"] diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 6865409cda..14098b2032 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -55,6 +55,9 @@ token-ceiling-filter = ["praxis-ai-proxy/token-ceiling-filter"] # Experimental: gates the inflight_tracker example test, which needs the # inflight_tracker filter registered in the in-process proxy registry. inflight-tracker-filter = ["praxis-ai-proxy/inflight-tracker-filter"] +# Experimental: gates the model_catalog example test, which needs the +# model_catalog filter registered in the in-process proxy registry. +model-catalog-filter = ["praxis-ai-proxy/model-catalog-filter"] llmd-ext-proc = ["praxis-test-utils/llmd-ext-proc"] no-mac-cert-rotation-tests = [] # We use this to disable testing cert rotation on macOS praxis-main = ["praxis-test-utils/praxis-main"] diff --git a/tests/integration/sdk/openai/test_openai_models.py b/tests/integration/sdk/openai/test_openai_models.py new file mode 100644 index 0000000000..ea75aa7126 --- /dev/null +++ b/tests/integration/sdk/openai/test_openai_models.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "openai>=2.0,<4", +# "pytest>=8.0", +# ] +# /// +"""OpenAI SDK compatibility tests for the principal-aware model catalog. + +Boots Praxis with the model-catalog example. Both upstream clusters point at +ports nothing listens on, so every assertion also shows the catalog answers +without contacting a provider. Asserts that the official OpenAI Python SDK can +list and retrieve models, that the listing depends on the Basic Auth subject, +and that a hidden model raises the same NotFoundError as an unknown one. + +Usage: + cargo build -p praxis-ai-proxy --features basic-auth-filter,model-catalog-filter + uv run tests/integration/sdk/openai/test_openai_models.py -v +""" + +from __future__ import annotations + +import base64 +import os +import re +import signal +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import pytest +from openai import NotFoundError, OpenAI + +REPO_ROOT = Path(__file__).resolve().parents[4] +CONFIG_PATH = REPO_ROOT / "examples/configs/model-catalog.yaml" + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _find_binary() -> str: + configured = os.environ.get("PRAXIS_AI_BIN") + if configured: + if os.path.isfile(configured): + return configured + raise FileNotFoundError(f"PRAXIS_AI_BIN={configured!r} not found") + for candidate in ("target/debug/praxis-ai", "target/release/praxis-ai"): + if os.path.isfile(candidate): + return candidate + raise FileNotFoundError( + "praxis-ai binary not found — run " + "`cargo build -p praxis-ai-proxy --features basic-auth-filter,model-catalog-filter` first" + ) + + +def _wait_for_port(port: int, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return + except OSError: + time.sleep(0.1) + raise TimeoutError(f"port {port} did not accept connections within {timeout}s") + + +def _example_password() -> str: + match = re.search(r'password:\s*"([^"]+)"', CONFIG_PATH.read_text()) + assert match, "model-catalog.yaml must declare a Basic Auth password" + return match.group(1) + + +def _patched_config(listener_port: int) -> str: + text = CONFIG_PATH.read_text() + text = text.replace("127.0.0.1:8080", f"127.0.0.1:{listener_port}") + # Unused ports: any upstream contact fails instead of silently succeeding. + text = text.replace("127.0.0.1:3000", f"127.0.0.1:{_free_port()}") + text = text.replace("127.0.0.1:3001", f"127.0.0.1:{_free_port()}") + return text + + +@pytest.fixture(scope="session") +def praxis_proxy(): + listener_port = _free_port() + fd, config_path = tempfile.mkstemp(suffix=".yaml") + with os.fdopen(fd, "w") as handle: + handle.write(_patched_config(listener_port)) + + proc = subprocess.Popen( + [_find_binary(), "-c", config_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + _wait_for_port(listener_port) + yield listener_port + finally: + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + os.unlink(config_path) + + +def _client(port: int, username: str) -> OpenAI: + token = base64.b64encode(f"{username}:{_example_password()}".encode()).decode() + return OpenAI( + api_key="not-needed", + base_url=f"http://127.0.0.1:{port}/v1", + default_headers={"Authorization": f"Basic {token}"}, + max_retries=0, + timeout=10.0, + ) + + +@pytest.fixture(scope="session") +def viewer(praxis_proxy: int) -> OpenAI: + return _client(praxis_proxy, "viewer") + + +@pytest.fixture(scope="session") +def ml_engineer(praxis_proxy: int) -> OpenAI: + return _client(praxis_proxy, "ml-engineer") + + +class TestOpenAIModels: + def test_list_is_filtered_per_principal(self, viewer: OpenAI, ml_engineer: OpenAI) -> None: + assert [m.id for m in viewer.models.list()] == ["gpt-4o", "gpt4o"] + assert [m.id for m in ml_engineer.models.list()] == ["gpt-4o", "gpt4o", "claude-sonnet-4-5"] + + def test_list_parses_openai_model_objects(self, viewer: OpenAI) -> None: + model = next(iter(viewer.models.list())) + assert model.object == "model" + assert model.created == 1735689600 + assert model.owned_by == "openai", "owned_by defaults to the provider" + extra = model.model_extra or {} + assert extra["praxis"]["capabilities"] == ["chat_completions", "responses"] + assert extra["praxis"]["aliases"] == ["gpt4o"] + + def test_retrieve_visible_model(self, ml_engineer: OpenAI) -> None: + model = ml_engineer.models.retrieve("claude-sonnet-4-5") + assert model.id == "claude-sonnet-4-5" + assert model.owned_by == "anthropic" + + def test_retrieve_alias(self, viewer: OpenAI) -> None: + model = viewer.models.retrieve("gpt4o") + assert (model.model_extra or {})["praxis"]["alias_of"] == "gpt-4o" + + def test_hidden_model_looks_unknown(self, viewer: OpenAI) -> None: + with pytest.raises(NotFoundError) as hidden: + viewer.models.retrieve("claude-sonnet-4-5") + with pytest.raises(NotFoundError) as unknown: + viewer.models.retrieve("no-such-model") + assert hidden.value.code == unknown.value.code == "model_not_found" + assert hidden.value.type == unknown.value.type == "invalid_request_error" + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, *sys.argv[1:]])) diff --git a/tests/integration/tests/suite/examples/mod.rs b/tests/integration/tests/suite/examples/mod.rs index 79e71c4a57..7342af0f43 100644 --- a/tests/integration/tests/suite/examples/mod.rs +++ b/tests/integration/tests/suite/examples/mod.rs @@ -74,6 +74,8 @@ mod lakera_guard; mod llmd_ext_proc; mod llmisvc_model_provider_resolver; mod mcp_broker; +#[cfg(all(feature = "model-catalog-filter", feature = "basic-auth-filter"))] +mod model_catalog; mod model_to_header; mod model_to_header_trust_existing; mod model_to_provider; diff --git a/tests/integration/tests/suite/examples/model_catalog.rs b/tests/integration/tests/suite/examples/model_catalog.rs new file mode 100644 index 0000000000..500c79ff2c --- /dev/null +++ b/tests/integration/tests/suite/examples/model_catalog.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Integration tests for the `model-catalog` example configuration. +//! +//! The catalog answers `GET /v1/models` from config, filtered by the subject +//! `basic_auth` verified, and must never contact an upstream to do so. Listed +//! IDs must stay routable through the `model_to_provider` chain behind it. + +use std::collections::HashMap; + +use praxis_test_utils::{ + ProxyGuard, StatefulCapturingBackend, StatefulCapturingGuard, basic_auth_header, example_config_path, free_port, + http_send, json_post_with_header, load_example_config, parse_body, parse_header, parse_status, start_proxy, +}; +use serde_json::Value; + +const BACKEND_RESPONSE: &str = r#"{"id":"chatcmpl-1","object":"chat.completion","choices":[]}"#; + +/// Running example proxy plus the backends behind both clusters. +struct Harness { + proxy: ProxyGuard, + openai: StatefulCapturingGuard, + vertex: StatefulCapturingGuard, + password: String, +} + +impl Harness { + fn start() -> Self { + let openai = StatefulCapturingBackend::new(vec![(200, BACKEND_RESPONSE.to_owned())]).start_with_shutdown(); + let vertex = StatefulCapturingBackend::new(vec![(200, BACKEND_RESPONSE.to_owned())]).start_with_shutdown(); + let config = load_example_config( + "model-catalog.yaml", + free_port(), + HashMap::from([("127.0.0.1:3000", openai.port()), ("127.0.0.1:3001", vertex.port())]), + ); + Self { + proxy: start_proxy(&config), + openai, + vertex, + password: password_from_example(), + } + } + + fn get(&self, path: &str, username: &str) -> String { + let authorization = basic_auth_header(username, &self.password); + http_send( + self.proxy.addr(), + &format!( + "GET {path} HTTP/1.1\r\n\ + Host: localhost\r\n\ + Authorization: {authorization}\r\n\ + Connection: close\r\n\r\n" + ), + ) + } + + fn listed_ids(&self, username: &str) -> Vec { + let raw = self.get("/v1/models", username); + assert_eq!(parse_status(&raw), 200, "the catalog list should succeed"); + let body: Value = serde_json::from_str(&parse_body(&raw)).expect("catalog list is JSON"); + body["data"] + .as_array() + .expect("catalog list has a data array") + .iter() + .map(|model| model["id"].as_str().expect("model id").to_owned()) + .collect() + } + + fn assert_no_upstream_contact(&self) { + assert!( + self.openai.requests().is_empty() && self.vertex.requests().is_empty(), + "catalog requests must be answered by the proxy" + ); + } +} + +/// Read the shared example password so it is not duplicated as a literal. +fn password_from_example() -> String { + let path = example_config_path("model-catalog.yaml"); + let yaml = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {path}: {error}")); + yaml.lines() + .find_map(|line| { + line.trim() + .strip_prefix("password:") + .map(|value| value.trim().trim_matches('"').to_owned()) + }) + .expect("model-catalog.yaml has a password field") +} + +#[test] +fn example_config_model_catalog_lists_models_per_principal() { + let harness = Harness::start(); + + assert_eq!(harness.listed_ids("viewer"), ["gpt-4o", "gpt4o"]); + assert_eq!( + harness.listed_ids("ml-engineer"), + ["gpt-4o", "gpt4o", "claude-sonnet-4-5"] + ); + + let raw = harness.get("/v1/models", "viewer"); + assert_eq!( + parse_header(&raw, "cache-control").as_deref(), + Some("private, no-store"), + "principal-specific listings must not be shared by caches" + ); + harness.assert_no_upstream_contact(); +} + +#[test] +fn example_config_model_catalog_hides_restricted_model_lookup() { + let harness = Harness::start(); + + let hidden = harness.get("/v1/models/claude-sonnet-4-5", "viewer"); + let unknown = harness.get("/v1/models/no-such-model", "viewer"); + assert_eq!(parse_status(&hidden), 404, "hidden models look unknown"); + assert_eq!(parse_status(&unknown), 404); + let hidden: Value = serde_json::from_str(&parse_body(&hidden)).expect("404 is JSON"); + assert_eq!(hidden["error"]["code"], "model_not_found"); + + let visible = harness.get("/v1/models/claude-sonnet-4-5", "ml-engineer"); + assert_eq!(parse_status(&visible), 200); + let visible: Value = serde_json::from_str(&parse_body(&visible)).expect("model is JSON"); + assert_eq!(visible["id"], "claude-sonnet-4-5"); + assert_eq!(visible["owned_by"], "anthropic"); + assert_eq!(visible["praxis"]["capabilities"], serde_json::json!(["messages"])); + + harness.assert_no_upstream_contact(); +} + +#[test] +fn example_config_model_catalog_requires_authentication() { + let harness = Harness::start(); + let raw = http_send( + harness.proxy.addr(), + "GET /v1/models HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + assert_eq!(parse_status(&raw), 401, "basic_auth runs before the catalog"); + harness.assert_no_upstream_contact(); +} + +#[test] +fn example_config_model_catalog_alias_routes_like_listed_id() { + let harness = Harness::start(); + assert!(harness.listed_ids("viewer").iter().any(|id| id == "gpt4o")); + + let authorization = format!("Authorization: {}", basic_auth_header("viewer", &harness.password)); + let raw = http_send( + harness.proxy.addr(), + &json_post_with_header( + "/v1/chat/completions", + r#"{"model":"gpt4o","messages":[{"role":"user","content":"hi"}]}"#, + &authorization, + ), + ); + assert_eq!(parse_status(&raw), 200, "a listed alias should route"); + + let forwarded = harness + .openai + .requests() + .into_iter() + .find(|request| request.uri.starts_with("/v1/chat/completions")) + .expect("the openai cluster receives the aliased request"); + let body: Value = serde_json::from_str(&forwarded.body).expect("forwarded body is JSON"); + assert_eq!(body["model"], "gpt-4o", "model_to_provider resolves the alias"); + assert!(harness.vertex.requests().is_empty()); +}