diff --git a/crates/api/src/routes/completions.rs b/crates/api/src/routes/completions.rs index 2c52aac16..71ce82b98 100644 --- a/crates/api/src/routes/completions.rs +++ b/crates/api/src/routes/completions.rs @@ -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)) diff --git a/crates/api/tests/e2e_all/general.rs b/crates/api/tests/e2e_all/general.rs index 83267683d..630364017 100644 --- a/crates/api/tests/e2e_all/general.rs +++ b/crates/api/tests/e2e_all/general.rs @@ -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"); @@ -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; diff --git a/crates/database/src/migrations/sql/V0069__mark_privacy_filter_non_generative.sql b/crates/database/src/migrations/sql/V0069__mark_privacy_filter_non_generative.sql new file mode 100644 index 000000000..70889104a --- /dev/null +++ b/crates/database/src/migrations/sql/V0069__mark_privacy_filter_non_generative.sql @@ -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;