Skip to content
Open
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
6 changes: 6 additions & 0 deletions crates/api/src/routes/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2680,6 +2680,12 @@ pub async fn models(

let response = ModelsResponse {
object: "list".to_string(),
// Every active model is listed, including non-generative ones such as
// the `openai/privacy-filter` token-classification model. Clients tell
// model kinds apart from the per-model `output_modalities` /
// `architecture.outputModalities` fields — a classification model
// reports `["classification"]`, an image model `["image"]` — rather
// than by absence from the catalog (issue #615).
data: models.into_iter().map(model_with_pricing_to_info).collect(),
};
Ok(ResponseJson(response))
Expand Down
90 changes: 87 additions & 3 deletions crates/api/tests/e2e_all/general.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ use inference_providers::{models::ChatCompletionChunk, StreamChunk};
#[tokio::test]
async fn test_models_api() {
let server = setup_test_server().await;
setup_qwen_model(&server).await;
let chat_id = setup_qwen_model(&server).await;
let (api_key, _) = create_org_and_api_key(&server).await;
let response = list_models(&server, api_key).await;

assert!(!response.data.is_empty());

// Verify pricing and context_length are present (HuggingFace integration)
let model = response.data.first().unwrap();
// Assert pricing/context_length on the model we configured, located by id.
// The e2e suite shares one model catalog, which can also contain other
// models (e.g. a zero-priced classification model), so keying off whichever
// entry happens to sort first would be order- and collation-dependent.
let model = response
.data
.iter()
.find(|m| m.id == chat_id)
.expect("configured chat model must be listed");
assert!(model.pricing.is_some(), "Model should have pricing");
let pricing = model.pricing.as_ref().unwrap();
assert!(pricing.input > 0.0, "Input price should be positive");
Expand All @@ -31,6 +38,83 @@ async fn test_models_api() {
);
}

/// A token-classification model (like `openai/privacy-filter`) does not serve
/// `/v1/chat/completions` — it exposes bespoke `/v1/privacy/*` routes — but it
/// must still be LISTED in `GET /v1/models`, tagged with its true modality, so a
/// client can tell it is not a completion model. This mirrors how image models
/// are listed and tagged (`outputModalities = ["image"]`) rather than hidden: it
/// is distinguished by its `output_modalities` / `architecture.outputModalities`
/// reporting `["classification"]`, alongside an ordinary chat model that still
/// reports `["text"]` (issue #615).
#[tokio::test]
async fn test_classification_model_listed_and_tagged() {
let server = setup_test_server().await;
let chat_id = setup_qwen_model(&server).await;

let classifier = "openai/privacy-filter".to_string();
let mut batch = BatchUpdateModelApiRequest::new();
batch.insert(
classifier.clone(),
serde_json::from_value(serde_json::json!({
"inputCostPerToken": { "amount": 1_000_000, "currency": "USD" },
"outputCostPerToken": { "amount": 0, "currency": "USD" },
"modelDisplayName": "Privacy Filter",
"modelDescription": "PII span detection (token classification)",
"contextLength": 512,
"maxOutputLength": 1024,
"verifiable": false,
"isActive": true,
"inputModalities": ["text"],
"outputModalities": ["classification"]
}))
.unwrap(),
);
admin_batch_upsert_models(&server, batch, get_session_id()).await;

let (api_key, _) = create_org_and_api_key(&server).await;
let response = list_models(&server, api_key).await;

// Both models are LISTED — the classifier is tagged, not hidden.
let chat = response
.data
.iter()
.find(|m| m.id == chat_id)
.expect("generative chat model must be listed");
let filter = response
.data
.iter()
.find(|m| m.id == classifier)
.unwrap_or_else(|| {
let ids: Vec<&str> = response.data.iter().map(|m| m.id.as_str()).collect();
panic!("classification model must be listed (tagged, not hidden); got {ids:?}")
});

// The classification model carries its true modality so a client can tell it
// is not a completion model — exactly how an image model reports ["image"].
// Both the OpenRouter-flat field and the nested architecture surface it.
assert_eq!(
filter.output_modalities,
Some(vec!["classification".to_string()]),
"privacy-filter must report output_modalities = [\"classification\"]"
);
assert_eq!(
filter
.architecture
.as_ref()
.map(|a| a.output_modalities.clone()),
Some(vec!["classification".to_string()]),
"privacy-filter architecture.outputModalities must be [\"classification\"]"
);

// The ordinary chat model still reports text output — the contrast a client
// uses to distinguish a completion model from the classifier.
assert_eq!(
chat.output_modalities,
Some(vec!["text".to_string()]),
"chat model must report output_modalities = [\"text\"]"
);
}

#[tokio::test]
async fn test_chat_completions_api() {
let server = setup_test_server().await;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- `openai/privacy-filter` is a token-classification (PII detection) model, not a
-- generative chat model. Its only real endpoints are /v1/privacy/classify and
-- /v1/privacy/redact; a /v1/chat/completions request against it returns an
-- upstream 404. It was nonetheless seeded with output_modalities = {'text'}, so
-- in GET /v1/models (and GET /v1/model/list) it was indistinguishable from an
-- ordinary chat model — clients had no way to tell it is not a completion model
-- (issue #615).
--
-- This migration TAGS the model with its true modality; it does NOT hide it. The
-- catalog already encodes model kind in output_modalities, and both listing
-- endpoints surface that field to clients (embedding models report
-- {'embedding'}, image models report {'image'}). The privacy filter keeps
-- appearing in the catalog exactly like an image or embedding model; correcting
-- its OUTPUT modality to 'classification' lets a client distinguish it from a
-- chat model instead of it masquerading as {'text'}. The input modality stays
-- {'text'} — it still consumes text. Every path (privacy classify/redact, direct
-- model lookup, admin) is unaffected.
--
-- Idempotent and operator-respecting: it only rewrites the row while it is still
-- on the wrong {'text'} label, mirroring how V0061 repaired a mislabeled catalog
-- row in place. It is a no-op on databases where the row does not exist (e.g.
-- fresh/test databases that seed the model after migrations run). The correction
-- is mirrored into the currently-open model_history snapshot so the audit trail
-- stays faithful, exactly as the app write path does.
-- Step 1: relabel the model row. output_modalities is a JSONB array of strings
-- (V0043), stored as e.g. '["text"]'; set it to the JSON array
-- '["classification"]'. Idempotent — skips the row once it is already fixed.
UPDATE models
SET
output_modalities = '["classification"]'::jsonb,
updated_at = NOW()
WHERE model_name = 'openai/privacy-filter'
AND output_modalities IS DISTINCT FROM '["classification"]'::jsonb;

-- Step 2: correct the currently-open history snapshot in place (a metadata fix,
-- not a new state transition). Kept independent of step 1 — joined to `models`
-- by name — so a prior manual patch to `models` still repairs the open snapshot.
UPDATE model_history mh
SET output_modalities = '["classification"]'::jsonb
FROM models m
WHERE m.model_name = 'openai/privacy-filter'
AND mh.model_id = m.id
AND mh.effective_until IS NULL
AND mh.output_modalities IS DISTINCT FROM '["classification"]'::jsonb;
Loading