Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/api/src/database_encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ const APPROVED: &[ApprovedGroup] = &[
ApprovedGroup { table: "organization_invitations", columns: &["email", "role", "status", "token", "email_status", "email_last_error", "email_message_id"], reason: "Invitation workflow data required for indexed acceptance and delivery operations; tokens are short-lived" },
ApprovedGroup { table: "organization_limits_history", columns: &["changed_by", "change_reason", "changed_by_user_email", "credit_type", "source", "currency"], reason: "Restricted billing and limits audit history" },
ApprovedGroup { table: "organization_member_role_audit_log", columns: &["previous_role", "new_role"], reason: "Restricted administrator member-role audit history" },
ApprovedGroup { table: "organization_model_access_log", columns: &["action"], reason: "Restricted administrator model-access audit history: a grant-or-revoke enum next to UUIDs, no customer content" },
ApprovedGroup { table: "organization_members", columns: &["role"], reason: "Queryable authorization role" },
ApprovedGroup { table: "organization_credit_consumption", columns: &["credit_type"], reason: "Restricted current billing consumption counter" },
ApprovedGroup { table: "organization_reporting_tokens", columns: &["name", "token_hash", "token_prefix"], reason: "Reporting credentials are one-way hashed; name and prefix are display metadata" },
Expand Down
53 changes: 39 additions & 14 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,12 +406,21 @@ pub async fn init_domain_services_with_pool(
database::repositories::OrganizationUsageRepository::new(database.pool().clone()),
) as Arc<dyn services::usage::UsageRepository>;

// Create models service. It owns the model-resolve cache (models and
// access decisions on unlisted models) that the attestation and completion
// services below share, so its invalidation reaches all three.
let models_service = Arc::new(services::models::ModelsServiceImpl::new(
inference_provider_pool.clone(),
models_repo.clone(),
));

// Create attestation service
let attestation_service = Arc::new(
services::attestation::AttestationService::init(
attestation_repo,
inference_provider_pool.clone(),
models_repo.clone(),
models_service.model_resolve_cache(),
metrics_service.clone(),
usage_repository_for_attestation,
config.ita.clone(),
Expand All @@ -420,12 +429,6 @@ pub async fn init_domain_services_with_pool(
.unwrap(),
);

// Create models service
let models_service = Arc::new(services::models::ModelsServiceImpl::new(
inference_provider_pool.clone(),
models_repo.clone(),
));

// Prepare repositories for usage service (will be created after workspace service)
let usage_repository = Arc::new(
database::repositories::OrganizationUsageRepository::with_accounting_config(
Expand Down Expand Up @@ -1861,11 +1864,14 @@ pub fn build_conversation_routes(
/// Route classification (nearai/infra#193) — see `routes/attestation.rs` for
/// the full table:
/// - `GET /v1/attestation/report` and `GET /v1/signature/{chat_id}` require an
/// API key (`auth_middleware_with_api_key`). The middleware validates the key
/// and confirms its workspace and organization remain active (rejecting
/// missing/invalid/expired/deactivated credentials with 401); like
/// the signature route, report retrieval is non-billable — no usage or
/// billing records are created.
/// API key (`auth_middleware_with_workspace_context`). The middleware
/// validates the key and confirms its workspace and organization remain
/// active (rejecting missing/invalid/expired/deactivated credentials with
/// 401); like the signature route, report retrieval is non-billable — no
/// usage or billing records are created. It is the same check as
/// `auth_middleware_with_api_key`, and it also hands the handler the key's
/// organization: the report route needs it to decide whether the caller
/// may ask about an unlisted model.
/// - `GET /v1/attestation/ita-token` and `GET /v1/attestation/pins` are
/// deliberately public; the rationale is documented on
/// `build_public_attestation_routes`.
Expand All @@ -1881,7 +1887,7 @@ pub fn build_attestation_routes(
.with_state(attestation_route_state.clone())
.layer(from_fn_with_state(
auth_state_middleware.clone(),
auth_middleware_with_api_key,
middleware::auth::auth_middleware_with_workspace_context,
));

// One section per attested backup provider; add new providers here.
Expand Down Expand Up @@ -2085,8 +2091,10 @@ pub fn build_gateway_routes(
/// `CLOUD_API_USAGE_TOKEN` service secret rather than the standard `sk-…`
/// API-key middleware. Mounted under `/v1/internal/*` after the global
/// `/v1` nest. Routes: `POST /internal/usage` (inference-proxy's
/// service-token usage reporter) and `GET /internal/models` (the model
/// catalog including unlisted models, for a gateway lane). Further internal
/// service-token usage reporter), `GET /internal/models` (the model
/// catalog including unlisted models, for a gateway lane) and
/// `GET /internal/model-access` (which organizations may use each unlisted
/// model, for a gateway lane that enforces access itself). Further internal
/// endpoints can be added here without touching the sk-auth stack.
pub fn build_internal_routes(app_state: AppState) -> Router {
Router::new()
Expand All @@ -2098,6 +2106,10 @@ pub fn build_internal_routes(app_state: AppState) -> Router {
"/internal/models",
get(crate::routes::completions::models_internal),
)
.route(
"/internal/model-access",
get(crate::routes::completions::model_access_internal),
)
.with_state(app_state)
}

Expand Down Expand Up @@ -2355,6 +2367,19 @@ fn build_admin_routes_with_options(
"/admin/models/{model_name}/deprecation/confirm",
axum::routing::post(confirm_model_deprecation),
)
.route(
"/admin/models/{model_name}/access",
axum::routing::get(crate::routes::admin_model_access::get_model_access),
)
.route(
"/admin/models/{model_name}/access/{org_id}",
axum::routing::put(crate::routes::admin_model_access::grant_model_access)
.delete(crate::routes::admin_model_access::revoke_model_access),
)
.route(
"/admin/organizations/{org_id}/model-access",
axum::routing::get(crate::routes::admin_model_access::list_organization_model_access),
)
.route("/admin/services", axum::routing::post(create_service))
.route("/admin/services/{id}", axum::routing::patch(update_service))
.route(
Expand Down
20 changes: 20 additions & 0 deletions crates/api/src/middleware/admin_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub(crate) fn admin_operation(method: &Method, matched_path: &str) -> AdminOpera
"/admin/models"
| "/admin/models/pricing-changes"
| "/admin/models/{model_name}/history"
| "/admin/models/{model_name}/access"
| "/admin/organizations/{org_id}/model-access"
| "/admin/organizations/{org_id}/limits/history"
| "/admin/organizations/{org_id}/usage/balance"
| "/admin/organizations/{org_id}/usage-discount"
Expand Down Expand Up @@ -140,6 +142,24 @@ mod tests {
}
}

#[test]
fn model_access_listings_are_reads_and_grant_and_revoke_are_writes() {
for prefix in ["/v1", ""] {
for listing in [
"/admin/models/{model_name}/access",
"/admin/organizations/{org_id}/model-access",
] {
let path = format!("{prefix}{listing}");
assert_eq!(admin_operation(&Method::GET, &path), AdminOperation::Read);
assert_eq!(admin_operation(&Method::PUT, &path), AdminOperation::Write);
}
let grant = format!("{prefix}/admin/models/{{model_name}}/access/{{org_id}}");
for method in [Method::PUT, Method::DELETE, Method::GET] {
assert_eq!(admin_operation(&method, &grant), AdminOperation::Write);
}
}
}

#[test]
fn api_keys_get_is_read() {
for path in ["/v1/admin/api-keys", "/admin/api-keys"] {
Expand Down
45 changes: 42 additions & 3 deletions crates/api/src/middleware/model_deprecation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//!
//! - `x-model-deprecation-date`: the planned deprecation date, in the same
//! form as `deprecation_date` on `GET /v1/models`.
//! - `x-model-successor`: the recommended replacement model, when one is set.
//! - `x-model-successor`: the recommended replacement model, when one is set
//! and the caller may use it.
//! - `x-model-pricing-change-date`: the earliest pending pricing change,
//! independently of whether the model has a planned deprecation.
//!
Expand All @@ -30,7 +31,9 @@ use axum::{
response::Response,
};
use chrono::{DateTime, Utc};
use services::models::{ModelWithPricing, ModelsServiceTrait};
use services::models::{ModelCaller, ModelWithPricing, ModelsServiceTrait};

use super::auth::AuthenticatedApiKey;

pub const HEADER_MODEL_DEPRECATION_DATE: &str = "x-model-deprecation-date";
pub const HEADER_MODEL_SUCCESSOR: &str = "x-model-successor";
Expand Down Expand Up @@ -119,6 +122,14 @@ pub async fn model_deprecation_middleware(
) -> Response {
let requested = RequestedModel::default();
request.extensions_mut().insert(requested.clone());
// Who is asking, read before the request moves on. The auth layer runs
// outside this one and has stored the API key's organization.
let caller = request
.extensions()
.get::<AuthenticatedApiKey>()
.map_or(ModelCaller::Anonymous, |api_key| {
ModelCaller::Organization(api_key.organization.id.0)
});

let mut response = next.run(request).await;

Expand All @@ -132,7 +143,35 @@ pub async fn model_deprecation_middleware(
// ago. An alias resolves to its canonical model. Advisory only: a failed
// or slow lookup (cache miss while the database is struggling) leaves the
// response as it is instead of holding it back.
let lookup = state.models_service.resolve_and_get_model(model_name);
//
// Resolved for the request's own caller, never as a trusted one. A
// successful status does not mean the handler resolved the model: a
// streaming Responses request answers 200 first and fails inside the
// stream. The headers of a model the caller may not use would tell it
// that the model exists.
//
// The successor is another model, possibly an unlisted one, so it is
// named only if it resolves for the same caller. Both lookups share the
// time limit.
let lookup = async {
let mut model = state
.models_service
.resolve_and_get_model(model_name, caller)
.await?;
if model.deprecation_date.is_some() {
if let Some(successor) = model.successor_model_name.take() {
if state
.models_service
.resolve_and_get_model(&successor, caller)
.await
.is_ok()
{
model.successor_model_name = Some(successor);
}
}
}
Ok::<_, services::models::ModelsError>(model)
};
let Ok(Ok(model)) = tokio::time::timeout(LOOKUP_TIMEOUT, lookup).await else {
return response;
};
Expand Down
47 changes: 37 additions & 10 deletions crates/api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,26 @@ pub struct ModelsResponse {
pub data: Vec<ModelInfo>,
}

/// `GET /v1/internal/model-access`: which organizations may use each
/// restricted model. A model that is absent from `models` is unrestricted.
#[derive(Debug, Serialize, Deserialize)]
pub struct ModelAccessDocument {
/// Always `"model_access"`.
pub object: String,
/// One entry per unlisted model, active or not, ordered by model name.
pub models: Vec<ModelAccessDocumentEntry>,
}

/// One restricted model of [`ModelAccessDocument`].
#[derive(Debug, Serialize, Deserialize)]
pub struct ModelAccessDocumentEntry {
/// Canonical model name, the `id` of `GET /v1/internal/models`.
pub id: String,
/// The active organizations that hold a grant. Empty when nobody may use
/// the model.
pub organization_ids: Vec<uuid::Uuid>,
}

/// Model pricing information.
///
/// `input` / `output` are the legacy HuggingFace-style fields (USD per million
Expand Down Expand Up @@ -1286,7 +1306,8 @@ pub struct ModelInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecation_date: Option<String>,
/// Recommended replacement for a model with a planned deprecation
/// (canonical model id). Omitted when none is set.
/// (canonical model id). Omitted when none is set. The public catalogs
/// name only a listed successor.
#[serde(skip_serializing_if = "Option::is_none")]
pub successor_model_id: Option<String>,
/// Human-readable description (OpenRouter `description`).
Expand Down Expand Up @@ -4121,16 +4142,18 @@ pub struct ModelMetadata {
#[serde(rename = "deprecationDate", skip_serializing_if = "Option::is_none")]
pub deprecation_date: Option<String>,
/// Recommended replacement for a model with a planned deprecation
/// (canonical model id). Omitted when none is set.
/// (canonical model id). Omitted when none is set. The public catalogs
/// name only a listed successor.
#[serde(rename = "successorModelId", skip_serializing_if = "Option::is_none")]
pub successor_model_id: Option<String>,
/// OpenRouter `openrouter.slug` override (lowercase `author/slug`). Omitted
/// when unset. On public `GET /v1/models` this surfaces as the nested
/// `openrouter: { slug }` object; the admin view exposes the raw value.
#[serde(rename = "openrouterSlug", skip_serializing_if = "Option::is_none")]
pub openrouter_slug: Option<String>,
/// Whether the model appears in the public model catalogs. Set on admin
/// views only; the public catalog endpoints omit it.
/// Whether the model is listed. An unlisted model is left out of the
/// public model catalogs and only organizations that hold a grant may
/// use it. Set on admin views only; the public catalog endpoints omit it.
#[serde(rename = "isListed", skip_serializing_if = "Option::is_none")]
pub is_listed: Option<bool>,
}
Expand Down Expand Up @@ -4185,11 +4208,14 @@ pub struct UpdateModelApiRequest {
/// Required to be set explicitly when activating a zero-price model.
#[serde(rename = "allowFree", skip_serializing_if = "Option::is_none")]
pub allow_free: Option<bool>,
/// `false` keeps the model out of the public model catalogs
/// (`GET /v1/models`, `GET /v1/model/list`, `GET /v1/model/{name}`) while
/// it stays active and billable; `true` lists it again. Omitted = leave
/// unchanged (new models are listed). It is a catalog switch only: a
/// model that has an inference URL can still be called by name.
/// `false` unlists the model. It stays active and billable, but it
/// leaves the public model catalogs (`GET /v1/models`,
/// `GET /v1/model/list`, `GET /v1/model/{name}`) and becomes restricted:
/// only organizations that hold a grant
/// (`PUT /v1/admin/models/{model_name}/access/{org_id}`) may use it, and
/// everyone else is answered as if the model did not exist. `true` lists
/// it again and opens it to every caller. Omitted = leave unchanged (new
/// models are listed).
#[serde(rename = "isListed", skip_serializing_if = "Option::is_none")]
pub is_listed: Option<bool>,
pub aliases: Option<Vec<String>>,
Expand Down Expand Up @@ -4664,7 +4690,8 @@ pub struct ModelHistoryEntry {
/// If true, this model was allowed to serve without pricing at this point in time.
#[serde(rename = "allowFree")]
pub allow_free: bool,
/// Whether the model was in the public model catalogs at this point in time.
/// Whether the model was listed (in the public model catalogs and open to
/// every caller) at this point in time.
#[serde(rename = "isListed")]
pub is_listed: bool,
}
Expand Down
8 changes: 8 additions & 0 deletions crates/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ use utoipa::{Modify, OpenApi};
crate::routes::admin::list_model_pricing_changes,
crate::routes::admin::cancel_model_pricing_change,
crate::routes::admin::get_model_history,
crate::routes::admin_model_access::get_model_access,
crate::routes::admin_model_access::grant_model_access,
crate::routes::admin_model_access::revoke_model_access,
crate::routes::admin_model_access::list_organization_model_access,
crate::routes::admin::get_admin_organization_balance,
crate::routes::admin::update_organization_limits,
crate::routes::admin::get_organization_limits_history,
Expand Down Expand Up @@ -256,6 +260,10 @@ use utoipa::{Modify, OpenApi};
UpdateOrganizationRequest, CreateApiKeyRequest, ApiKeyResponse,
UpdateApiKeySpendLimitRequest, UpdateApiKeyRequest,
// usage_hourly repair models
crate::routes::admin_model_access::ModelAccessResponse,
crate::routes::admin_model_access::ModelAccessGrantResponse,
crate::routes::admin_model_access::OrganizationModelAccessResponse,
crate::routes::admin_model_access::OrganizationModelAccessEntry,
crate::routes::admin_usage_discount::UsageDiscountRequest,
crate::routes::admin_credit_reconciliation::CreditReconciliationRequest,
crate::routes::admin_credit_reconciliation::CreditReconciliationResponse,
Expand Down
Loading
Loading