From e6e3311bbf40dc659ec2cee954543620bba30cbe Mon Sep 17 00:00:00 2001 From: Tejas Lokeshrao Date: Mon, 17 Aug 2026 14:34:09 -0400 Subject: [PATCH 1/4] fix: enforce configured model input modalities --- .../impl/model/AbstractModelEngine.java | 66 +++++++++ ...ractModelEngineInputModalityUnitTests.java | 138 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java diff --git a/src/prerna/engine/impl/model/AbstractModelEngine.java b/src/prerna/engine/impl/model/AbstractModelEngine.java index 592d02e4dd..f53e7f6390 100644 --- a/src/prerna/engine/impl/model/AbstractModelEngine.java +++ b/src/prerna/engine/impl/model/AbstractModelEngine.java @@ -28,11 +28,15 @@ package prerna.engine.impl.model; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Properties; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -46,6 +50,8 @@ import prerna.engine.impl.model.inferencetracking.ModelInferenceLogsUtils; import prerna.engine.impl.model.message.AbstractMessage; import prerna.engine.impl.model.message.InputMessage; +import prerna.engine.impl.model.message.MessagePart; +import prerna.engine.impl.model.message.MessagePartType; import prerna.engine.impl.model.message.MessageUtils; import prerna.engine.impl.model.message.ResponseMessage; import prerna.engine.impl.model.responses.AskErrorModelEngineResponse; @@ -133,6 +139,12 @@ public abstract class AbstractModelEngine extends AbstractEngine implements IMod */ protected Boolean temperatureSupported = null; + /** + * Input modalities configured in MODELMETADATA. Null means the metadata does + * not restrict request content. + */ + protected Set inputModalities = null; + @Override public void open(Properties smssProp) throws Exception { super.open(smssProp); @@ -198,6 +210,7 @@ private void fillModelSettingsFromMetadata() { fillIfMissing("context_window", metadata.get("contextWindow")); fillIfMissing("max_tokens", metadata.get("maxOutputTokens")); this.builtinTools = metadata.get("builtinTools"); + this.inputModalities = toModalitySet(metadata.get("inputModalities")); if (metadata.get("reasoning") instanceof Boolean) { this.reasoning = (Boolean) metadata.get("reasoning"); @@ -212,6 +225,19 @@ private void fillModelSettingsFromMetadata() { } } + private static Set toModalitySet(Object value) { + if (!(value instanceof Collection)) { + return null; + } + Set modalities = new LinkedHashSet<>(); + for (Object modality : (Collection) value) { + if (modality != null && !modality.toString().isBlank()) { + modalities.add(modality.toString().trim().toUpperCase(Locale.ROOT)); + } + } + return modalities.isEmpty() ? null : modalities; + } + /** * Set the smss property to the metadata value only when the smss file does not * already define a non-empty value for the key. @@ -467,6 +493,8 @@ public AskModelEngineResponse askRoom(String question, Room room, AbstractMessag question = MessageUtils.toJsonArray(room.getMessages()); } + validateInputModalities(room.getMessages(), inputMessage); + ZonedDateTime inputTime = ZonedDateTime.now(); AskModelEngineResponse askModelResponse = askCall(question, null, context, room.getInsight(), room.getId(), parameters); @@ -573,6 +601,44 @@ public AskModelEngineResponse askRoom(String question, Room room, AbstractMessag } } + void validateInputModalities(List messages, AbstractMessage inputMessage) { + if (this.inputModalities == null) { + return; + } + List requestMessages = messages == null ? List.of() : messages; + if (inputMessage != null) { + requestMessages = MessageUtils.getMessageBranchWithNewMessage(requestMessages, inputMessage); + } + for (AbstractMessage message : requestMessages) { + validateInputModalities(message); + } + } + + private void validateInputModalities(AbstractMessage message) { + if (message == null) { + return; + } + for (MessagePart part : message.getParts()) { + String modality = modalityFor(part); + if (modality != null && !this.inputModalities.contains(modality)) { + String model = this.engineName == null || this.engineName.isBlank() ? this.engineId : this.engineName; + throw new IllegalArgumentException("Model " + model + " does not allow " + modality + + " input. Configured input modalities: " + this.inputModalities); + } + } + } + + private static String modalityFor(MessagePart part) { + if (part == null) { + return null; + } + return switch (part.getType()) { + case TEXT, SYSTEM -> MessagePartType.TEXT.name(); + case MEDIA -> AskModelEngineResponse.IMAGE; + default -> null; + }; + } + @Override @Deprecated public AskModelEngineResponse ask(String question, String context, Insight insight, diff --git a/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java b/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java new file mode 100644 index 0000000000..e46f96a2ff --- /dev/null +++ b/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.impl.model; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import prerna.engine.api.ModelTypeEnum; +import prerna.engine.impl.model.message.InputMessage; +import prerna.engine.impl.model.message.MediaMessagePart; +import prerna.engine.impl.model.message.MessageInputMedia; +import prerna.engine.impl.model.message.MessagePartType; +import prerna.engine.impl.model.message.TextMessagePart; +import prerna.engine.impl.model.responses.AskModelEngineResponse; +import prerna.engine.impl.model.responses.EmbeddingsModelEngineResponse; +import prerna.om.Insight; + +class AbstractModelEngineInputModalityUnitTests { + + @Test + void rejectsImageWhenModelOnlyAllowsTextInput() { + TestModelEngine engine = new TestModelEngine(Set.of(MessagePartType.TEXT.name())); + InputMessage message = newMessage(); + message.addPart(new TextMessagePart("describe this")); + message.addPart(new MediaMessagePart(MessageInputMedia.fromUrl("https://example.com/image.png"))); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> engine.validateInputModalities(List.of(), message)); + + assertEquals("Model test-model does not allow IMAGE input. Configured input modalities: [TEXT]", + exception.getMessage()); + } + + @Test + void acceptsImageWhenModelAllowsImageInput() { + TestModelEngine engine = new TestModelEngine( + Set.of(MessagePartType.TEXT.name(), AskModelEngineResponse.IMAGE)); + InputMessage message = newMessage(); + message.addPart(new TextMessagePart("describe this")); + message.addPart(new MediaMessagePart(MessageInputMedia.fromUrl("https://example.com/image.png"))); + + assertDoesNotThrow(() -> engine.validateInputModalities(List.of(), message)); + } + + @Test + void doesNotRestrictInputWhenMetadataDoesNotConfigureModalities() { + TestModelEngine engine = new TestModelEngine(null); + InputMessage message = newMessage(); + message.addPart(new MediaMessagePart(MessageInputMedia.fromUrl("https://example.com/image.png"))); + + assertDoesNotThrow(() -> engine.validateInputModalities(List.of(), message)); + } + + @Test + void ignoresUnsupportedPartsOnConversationBranchesNotSentToModel() { + TestModelEngine engine = new TestModelEngine(Set.of(MessagePartType.TEXT.name())); + InputMessage root = newMessage(); + root.addPart(new TextMessagePart("root")); + InputMessage imageBranch = newMessage(); + imageBranch.setParentMessageId(root.getMessageId()); + imageBranch.addPart(new MediaMessagePart(MessageInputMedia.fromUrl("https://example.com/image.png"))); + InputMessage textBranch = newMessage(); + textBranch.setParentMessageId(root.getMessageId()); + textBranch.addPart(new TextMessagePart("continue here")); + + assertDoesNotThrow( + () -> engine.validateInputModalities(List.of(root, imageBranch), textBranch)); + } + + private static InputMessage newMessage() { + Room room = new Room(); + room.setId("test-room"); + return InputMessage.builder(room).build(); + } + + private static class TestModelEngine extends AbstractModelEngine { + + private TestModelEngine(Set inputModalities) { + this.inputModalities = inputModalities; + setEngineName("test-model"); + } + + @Override + protected AskModelEngineResponse askCall(String question, Object fullPrompt, String context, Insight insight, + String roomId, Map hyperParameters) { + return null; + } + + @Override + protected EmbeddingsModelEngineResponse embeddingsCall(List stringsToEmbed, Insight insight, + Map parameters) { + return null; + } + + @Override + public ModelTypeEnum getModelType() { + return ModelTypeEnum.OPEN_AI; + } + + @Override + public void close() throws IOException { + // Nothing to close in this test engine. + } + } +} From adaeac8bfe57e5877421a2d3de7467d7e91abdd2 Mon Sep 17 00:00:00 2001 From: Tejas Lokeshrao Date: Mon, 17 Aug 2026 16:22:11 -0400 Subject: [PATCH 2/4] fix: classify media input modalities by MIME type --- .../impl/model/AbstractModelEngine.java | 26 ++++++++++++++++++- ...ractModelEngineInputModalityUnitTests.java | 20 ++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/prerna/engine/impl/model/AbstractModelEngine.java b/src/prerna/engine/impl/model/AbstractModelEngine.java index f53e7f6390..1f65a61a6b 100644 --- a/src/prerna/engine/impl/model/AbstractModelEngine.java +++ b/src/prerna/engine/impl/model/AbstractModelEngine.java @@ -50,6 +50,8 @@ import prerna.engine.impl.model.inferencetracking.ModelInferenceLogsUtils; import prerna.engine.impl.model.message.AbstractMessage; import prerna.engine.impl.model.message.InputMessage; +import prerna.engine.impl.model.message.MediaMessagePart; +import prerna.engine.impl.model.message.MessageInputMedia; import prerna.engine.impl.model.message.MessagePart; import prerna.engine.impl.model.message.MessagePartType; import prerna.engine.impl.model.message.MessageUtils; @@ -67,6 +69,8 @@ public abstract class AbstractModelEngine extends AbstractEngine implements IModelEngine { private static final Logger classLogger = LogManager.getLogger(AbstractModelEngine.class); + private static final String FILE_INPUT_MODALITY = "FILE"; + private static final String PDF_INPUT_MODALITY = "PDF"; public static final String OPEN_AI_KEY = "OPEN_AI_KEY"; public static final String AWS_SECRET_KEY = "AWS_SECRET_KEY"; @@ -634,11 +638,31 @@ private static String modalityFor(MessagePart part) { } return switch (part.getType()) { case TEXT, SYSTEM -> MessagePartType.TEXT.name(); - case MEDIA -> AskModelEngineResponse.IMAGE; + case MEDIA -> part instanceof MediaMessagePart ? modalityFor((MediaMessagePart) part) : FILE_INPUT_MODALITY; default -> null; }; } + private static String modalityFor(MediaMessagePart part) { + MessageInputMedia media = part.getMediaInfo(); + String mimeType = media == null ? null : media.getMimeType(); + if (mimeType == null || mimeType.isBlank()) { + // URL media currently represents image input and does not carry a MIME type. + return AskModelEngineResponse.IMAGE; + } + + String[] mimeParts = mimeType.split("/", 2); + String mimeFamily = mimeParts[0].trim().toUpperCase(Locale.ROOT); + if (mimeFamily.equals(AskModelEngineResponse.IMAGE) || mimeFamily.equals("AUDIO") + || mimeFamily.equals("VIDEO")) { + return mimeFamily; + } + if (mimeParts.length == 2 && PDF_INPUT_MODALITY.equalsIgnoreCase(mimeParts[1].trim())) { + return PDF_INPUT_MODALITY; + } + return FILE_INPUT_MODALITY; + } + @Override @Deprecated public AskModelEngineResponse ask(String question, String context, Insight insight, diff --git a/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java b/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java index e46f96a2ff..e456e3d87c 100644 --- a/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java +++ b/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java @@ -30,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.util.List; @@ -38,6 +39,8 @@ import org.junit.jupiter.api.Test; +import com.google.gson.Gson; + import prerna.engine.api.ModelTypeEnum; import prerna.engine.impl.model.message.InputMessage; import prerna.engine.impl.model.message.MediaMessagePart; @@ -75,6 +78,19 @@ void acceptsImageWhenModelAllowsImageInput() { assertDoesNotThrow(() -> engine.validateInputModalities(List.of(), message)); } + @Test + void rejectsPdfWhenModelDoesNotAllowPdfInput() { + TestModelEngine engine = new TestModelEngine( + Set.of(MessagePartType.TEXT.name(), AskModelEngineResponse.IMAGE)); + InputMessage message = newMessage(); + message.addPart(new MediaMessagePart(pdfMedia())); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> engine.validateInputModalities(List.of(), message)); + + assertTrue(exception.getMessage().contains("does not allow PDF input")); + } + @Test void doesNotRestrictInputWhenMetadataDoesNotConfigureModalities() { TestModelEngine engine = new TestModelEngine(null); @@ -106,6 +122,10 @@ private static InputMessage newMessage() { return InputMessage.builder(room).build(); } + private static MessageInputMedia pdfMedia() { + return new Gson().fromJson("{\"mimeType\":\"application/pdf\"}", MessageInputMedia.class); + } + private static class TestModelEngine extends AbstractModelEngine { private TestModelEngine(Set inputModalities) { From 0084df70e14de858cee746620cf8d748ffeffa43 Mon Sep 17 00:00:00 2001 From: Tejas Lokeshrao Date: Tue, 18 Aug 2026 12:14:35 -0400 Subject: [PATCH 3/4] fix: create modality enum --- .../utils/SecurityModelMetadataUtils.java | 8 +- src/prerna/engine/api/ModelModalityEnum.java | 84 +++++++++++++++++++ .../impl/model/AbstractModelEngine.java | 29 +++---- .../util/StaticModelMetadataCatalog.java | 23 +++-- .../api/ModelModalityEnumUnitTests.java | 56 +++++++++++++ ...ractModelEngineInputModalityUnitTests.java | 10 +-- .../impl/model/RoomMessageStoreUnitTests.java | 82 ++++++++++++++++++ 7 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 src/prerna/engine/api/ModelModalityEnum.java create mode 100644 test/prerna/engine/api/ModelModalityEnumUnitTests.java create mode 100644 test/prerna/engine/impl/model/RoomMessageStoreUnitTests.java diff --git a/src/prerna/auth/utils/SecurityModelMetadataUtils.java b/src/prerna/auth/utils/SecurityModelMetadataUtils.java index 1a589fd8c9..492ee0511e 100644 --- a/src/prerna/auth/utils/SecurityModelMetadataUtils.java +++ b/src/prerna/auth/utils/SecurityModelMetadataUtils.java @@ -60,6 +60,7 @@ import prerna.engine.api.IEngine; import prerna.engine.api.IRDBMSEngine; +import prerna.engine.api.ModelModalityEnum; import prerna.util.ConnectionUtils; import prerna.util.Constants; import prerna.util.DIHelper; @@ -82,8 +83,6 @@ public final class SecurityModelMetadataUtils extends AbstractSecurityUtils { private static final Set CAPABILITIES = Set.of("TEXT_GENERATION", "IMAGE_GENERATION", "VIDEO_GENERATION", "EMBEDDING", "TRANSCRIPTION", "SPEECH_SYNTHESIS", "RERANKING", "MODERATION"); - private static final Set MODALITIES = Set.of("TEXT", "IMAGE", "AUDIO", "VIDEO", "VECTOR", "FILE", - "PDF"); private static final Set EDITABLE_METADATA_KEYS = Set.of(Constants.MODEL_PROVIDER, Constants.SERVING_PROVIDER, Constants.MODEL_CAPABILITY, Constants.INPUT_MODALITIES, Constants.OUTPUT_MODALITIES, Constants.CONTEXT_WINDOW, Constants.MAX_TOKENS, Constants.BUILTIN_TOOLS, @@ -733,10 +732,7 @@ private static void normalizeListProperty(Map details, String ke continue; } if (modality) { - value = value.toUpperCase(Locale.ROOT); - if (!MODALITIES.contains(value)) { - throw new IllegalArgumentException("Unsupported modality " + value); - } + value = ModelModalityEnum.fromName(value).name(); } else { value = value.toLowerCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); if (!LOWER_SNAKE_CASE_PATTERN.matcher(value).matches()) { diff --git a/src/prerna/engine/api/ModelModalityEnum.java b/src/prerna/engine/api/ModelModalityEnum.java new file mode 100644 index 0000000000..e4c2fc7329 --- /dev/null +++ b/src/prerna/engine/api/ModelModalityEnum.java @@ -0,0 +1,84 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.api; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Content modalities supported by model metadata and message validation. + * Metadata is persisted using the enum names in upper case; catalog files use + * the lower-case form returned by {@link #getCatalogName()}. + */ +public enum ModelModalityEnum { + TEXT, IMAGE, AUDIO, VIDEO, VECTOR, FILE, PDF; + + private static final Set NAMES; + + static { + Set names = new LinkedHashSet<>(); + Arrays.stream(values()).map(ModelModalityEnum::name).forEach(names::add); + NAMES = Collections.unmodifiableSet(names); + } + + /** + * Parse a metadata or catalog modality name case-insensitively. + * + * @param value modality name + * @return the matching modality + * @throws IllegalArgumentException when the value is null, blank, or unknown + */ + public static ModelModalityEnum fromName(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Unsupported modality " + value); + } + String normalizedValue = value.trim().toUpperCase(Locale.ROOT); + try { + return valueOf(normalizedValue); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unsupported modality " + normalizedValue, e); + } + } + + /** + * @return the upper-case names accepted in model metadata + */ + public static Set names() { + return NAMES; + } + + /** + * @return the lower-case spelling used by the static model catalog + */ + public String getCatalogName() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/prerna/engine/impl/model/AbstractModelEngine.java b/src/prerna/engine/impl/model/AbstractModelEngine.java index d07c523a9d..b748944d0e 100644 --- a/src/prerna/engine/impl/model/AbstractModelEngine.java +++ b/src/prerna/engine/impl/model/AbstractModelEngine.java @@ -33,7 +33,6 @@ import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; @@ -46,6 +45,7 @@ import prerna.auth.utils.SecurityModelMetadataUtils; import prerna.engine.api.IEngine; import prerna.engine.api.IModelEngine; +import prerna.engine.api.ModelModalityEnum; import prerna.engine.impl.AbstractEngine; import prerna.engine.impl.model.inferencetracking.ModelInferenceLogsUtils; import prerna.engine.impl.model.message.AbstractMessage; @@ -69,8 +69,6 @@ public abstract class AbstractModelEngine extends AbstractEngine implements IModelEngine { private static final Logger classLogger = LogManager.getLogger(AbstractModelEngine.class); - private static final String FILE_INPUT_MODALITY = "FILE"; - private static final String PDF_INPUT_MODALITY = "PDF"; public static final String OPEN_AI_KEY = "OPEN_AI_KEY"; public static final String AWS_SECRET_KEY = "AWS_SECRET_KEY"; @@ -236,7 +234,7 @@ private static Set toModalitySet(Object value) { Set modalities = new LinkedHashSet<>(); for (Object modality : (Collection) value) { if (modality != null && !modality.toString().isBlank()) { - modalities.add(modality.toString().trim().toUpperCase(Locale.ROOT)); + modalities.add(ModelModalityEnum.fromName(modality.toString()).name()); } } return modalities.isEmpty() ? null : modalities; @@ -637,8 +635,9 @@ private static String modalityFor(MessagePart part) { return null; } return switch (part.getType()) { - case TEXT, SYSTEM -> MessagePartType.TEXT.name(); - case MEDIA -> part instanceof MediaMessagePart ? modalityFor((MediaMessagePart) part) : FILE_INPUT_MODALITY; + case TEXT, SYSTEM -> ModelModalityEnum.TEXT.name(); + case MEDIA -> part instanceof MediaMessagePart ? modalityFor((MediaMessagePart) part) + : ModelModalityEnum.FILE.name(); default -> null; }; } @@ -648,19 +647,21 @@ private static String modalityFor(MediaMessagePart part) { String mimeType = media == null ? null : media.getMimeType(); if (mimeType == null || mimeType.isBlank()) { // URL media currently represents image input and does not carry a MIME type. - return AskModelEngineResponse.IMAGE; + return ModelModalityEnum.IMAGE.name(); } String[] mimeParts = mimeType.split("/", 2); - String mimeFamily = mimeParts[0].trim().toUpperCase(Locale.ROOT); - if (mimeFamily.equals(AskModelEngineResponse.IMAGE) || mimeFamily.equals("AUDIO") - || mimeFamily.equals("VIDEO")) { - return mimeFamily; + String mimeFamily = mimeParts[0].trim(); + if (ModelModalityEnum.IMAGE.getCatalogName().equalsIgnoreCase(mimeFamily) + || ModelModalityEnum.AUDIO.getCatalogName().equalsIgnoreCase(mimeFamily) + || ModelModalityEnum.VIDEO.getCatalogName().equalsIgnoreCase(mimeFamily)) { + return ModelModalityEnum.fromName(mimeFamily).name(); } - if (mimeParts.length == 2 && PDF_INPUT_MODALITY.equalsIgnoreCase(mimeParts[1].trim())) { - return PDF_INPUT_MODALITY; + if (mimeParts.length == 2 + && ModelModalityEnum.PDF.getCatalogName().equalsIgnoreCase(mimeParts[1].trim())) { + return ModelModalityEnum.PDF.name(); } - return FILE_INPUT_MODALITY; + return ModelModalityEnum.FILE.name(); } /** diff --git a/src/prerna/util/StaticModelMetadataCatalog.java b/src/prerna/util/StaticModelMetadataCatalog.java index e4f5e7bd39..ef23916013 100644 --- a/src/prerna/util/StaticModelMetadataCatalog.java +++ b/src/prerna/util/StaticModelMetadataCatalog.java @@ -60,6 +60,7 @@ import com.google.gson.reflect.TypeToken; import prerna.auth.utils.SecurityModelMetadataUtils; +import prerna.engine.api.ModelModalityEnum; /** * Read-only view over the curated model catalog stored in meta/model.json. @@ -279,29 +280,35 @@ private static String inferCapability(List inputModalities, List if (outputModalities.isEmpty()) { return null; } - if (outputModalities.contains("video")) { + if (containsModality(outputModalities, ModelModalityEnum.VIDEO)) { return "VIDEO_GENERATION"; } - if (outputModalities.contains("image")) { + if (containsModality(outputModalities, ModelModalityEnum.IMAGE)) { return "IMAGE_GENERATION"; } - if (outputModalities.contains("audio")) { - return inputModalities.contains("text") ? "SPEECH_SYNTHESIS" : "TRANSCRIPTION"; + if (containsModality(outputModalities, ModelModalityEnum.AUDIO)) { + return containsModality(inputModalities, ModelModalityEnum.TEXT) ? "SPEECH_SYNTHESIS" + : "TRANSCRIPTION"; } - if (outputModalities.contains("vector")) { + if (containsModality(outputModalities, ModelModalityEnum.VECTOR)) { return "EMBEDDING"; } - if (outputModalities.contains("text")) { - if (!inputModalities.contains("text") && inputModalities.contains("audio")) { + if (containsModality(outputModalities, ModelModalityEnum.TEXT)) { + if (!containsModality(inputModalities, ModelModalityEnum.TEXT) + && containsModality(inputModalities, ModelModalityEnum.AUDIO)) { return "TRANSCRIPTION"; } - if (inputModalities.contains("text") && !placeholderOutputLimit) { + if (containsModality(inputModalities, ModelModalityEnum.TEXT) && !placeholderOutputLimit) { return "TEXT_GENERATION"; } } return null; } + private static boolean containsModality(List modalities, ModelModalityEnum modality) { + return modalities.stream().anyMatch(modalityName -> modality.getCatalogName().equalsIgnoreCase(modalityName)); + } + /** * The catalog records an output limit of 0 or 1 for models that do not produce * a text completion at all. diff --git a/test/prerna/engine/api/ModelModalityEnumUnitTests.java b/test/prerna/engine/api/ModelModalityEnumUnitTests.java new file mode 100644 index 0000000000..0a9f9ff46d --- /dev/null +++ b/test/prerna/engine/api/ModelModalityEnumUnitTests.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ModelModalityEnumUnitTests { + + @Test + void parsesMetadataAndCatalogNames() { + assertEquals(ModelModalityEnum.PDF, ModelModalityEnum.fromName(" pdf ")); + assertEquals(ModelModalityEnum.IMAGE, ModelModalityEnum.fromName("IMAGE")); + assertEquals("image", ModelModalityEnum.IMAGE.getCatalogName()); + } + + @Test + void exposesMetadataNamesInEnumOrder() { + assertEquals(List.of("TEXT", "IMAGE", "AUDIO", "VIDEO", "VECTOR", "FILE", "PDF"), + List.copyOf(ModelModalityEnum.names())); + } + + @Test + void rejectsUnknownNames() { + assertThrows(IllegalArgumentException.class, () -> ModelModalityEnum.fromName("spreadsheet")); + } +} diff --git a/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java b/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java index e456e3d87c..6e8ddc79f9 100644 --- a/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java +++ b/test/prerna/engine/impl/model/AbstractModelEngineInputModalityUnitTests.java @@ -41,11 +41,11 @@ import com.google.gson.Gson; +import prerna.engine.api.ModelModalityEnum; import prerna.engine.api.ModelTypeEnum; import prerna.engine.impl.model.message.InputMessage; import prerna.engine.impl.model.message.MediaMessagePart; import prerna.engine.impl.model.message.MessageInputMedia; -import prerna.engine.impl.model.message.MessagePartType; import prerna.engine.impl.model.message.TextMessagePart; import prerna.engine.impl.model.responses.AskModelEngineResponse; import prerna.engine.impl.model.responses.EmbeddingsModelEngineResponse; @@ -55,7 +55,7 @@ class AbstractModelEngineInputModalityUnitTests { @Test void rejectsImageWhenModelOnlyAllowsTextInput() { - TestModelEngine engine = new TestModelEngine(Set.of(MessagePartType.TEXT.name())); + TestModelEngine engine = new TestModelEngine(Set.of(ModelModalityEnum.TEXT.name())); InputMessage message = newMessage(); message.addPart(new TextMessagePart("describe this")); message.addPart(new MediaMessagePart(MessageInputMedia.fromUrl("https://example.com/image.png"))); @@ -70,7 +70,7 @@ void rejectsImageWhenModelOnlyAllowsTextInput() { @Test void acceptsImageWhenModelAllowsImageInput() { TestModelEngine engine = new TestModelEngine( - Set.of(MessagePartType.TEXT.name(), AskModelEngineResponse.IMAGE)); + Set.of(ModelModalityEnum.TEXT.name(), ModelModalityEnum.IMAGE.name())); InputMessage message = newMessage(); message.addPart(new TextMessagePart("describe this")); message.addPart(new MediaMessagePart(MessageInputMedia.fromUrl("https://example.com/image.png"))); @@ -81,7 +81,7 @@ void acceptsImageWhenModelAllowsImageInput() { @Test void rejectsPdfWhenModelDoesNotAllowPdfInput() { TestModelEngine engine = new TestModelEngine( - Set.of(MessagePartType.TEXT.name(), AskModelEngineResponse.IMAGE)); + Set.of(ModelModalityEnum.TEXT.name(), ModelModalityEnum.IMAGE.name())); InputMessage message = newMessage(); message.addPart(new MediaMessagePart(pdfMedia())); @@ -102,7 +102,7 @@ void doesNotRestrictInputWhenMetadataDoesNotConfigureModalities() { @Test void ignoresUnsupportedPartsOnConversationBranchesNotSentToModel() { - TestModelEngine engine = new TestModelEngine(Set.of(MessagePartType.TEXT.name())); + TestModelEngine engine = new TestModelEngine(Set.of(ModelModalityEnum.TEXT.name())); InputMessage root = newMessage(); root.addPart(new TextMessagePart("root")); InputMessage imageBranch = newMessage(); diff --git a/test/prerna/engine/impl/model/RoomMessageStoreUnitTests.java b/test/prerna/engine/impl/model/RoomMessageStoreUnitTests.java new file mode 100644 index 0000000000..78d50f6ea0 --- /dev/null +++ b/test/prerna/engine/impl/model/RoomMessageStoreUnitTests.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * Licensed under the Apache License, Version 2.0 (the "License"); + ******************************************************************************/ +package prerna.engine.impl.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import prerna.engine.impl.model.message.AbstractMessage; + +class RoomMessageStoreUnitTests { + + private static final String INVALID_PARENT_JSON = "[" + + "{\"io\":\"INPUT\",\"messageId\":\"child\",\"parentMessageId\":\"missing\",\"parts\":[]}" + + "]"; + + @Test + void loadAllowsMessagesWithInvalidParents() { + Room room = roomWithId(); + + List messages = RoomMessageStore.loadFromPersistedJson(room, INVALID_PARENT_JSON); + + assertEquals(1, messages.size()); + assertEquals("child", messages.get(0).getMessageId()); + assertEquals("missing", messages.get(0).getParentMessageId()); + } + + @Test + void providerPayloadRejectsMessagesWithInvalidParents() { + Room room = roomWithId(); + List messages = RoomMessageStore.loadFromPersistedJson(room, INVALID_PARENT_JSON); + + assertThrows(IllegalStateException.class, + () -> RoomMessageStore.providerMessageHistory(room, messages)); + } + + @Test + void persistRejectsMessagesWithInvalidParents() { + Room room = roomWithId(); + room.setMessages(RoomMessageStore.loadFromPersistedJson(room, INVALID_PARENT_JSON)); + + assertThrows(IllegalStateException.class, () -> RoomMessageStore.persist(room, "user-id")); + } + + private static Room roomWithId() { + Room room = new Room(); + room.setId("room-id"); + return room; + } +} From ba5210b4bc5ad3d4bcaff3a8d0a7a29c62d9c7a2 Mon Sep 17 00:00:00 2001 From: Tejas Lokeshrao Date: Tue, 18 Aug 2026 13:21:21 -0400 Subject: [PATCH 4/4] fix: add model capability enum --- .../utils/SecurityModelMetadataUtils.java | 16 ++-- .../engine/api/ModelCapabilityEnum.java | 76 +++++++++++++++++++ src/prerna/reactor/agent/AgentRunner.java | 4 +- .../util/StaticModelMetadataCatalog.java | 15 ++-- .../api/ModelCapabilityEnumUnitTests.java | 56 ++++++++++++++ 5 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 src/prerna/engine/api/ModelCapabilityEnum.java create mode 100644 test/prerna/engine/api/ModelCapabilityEnumUnitTests.java diff --git a/src/prerna/auth/utils/SecurityModelMetadataUtils.java b/src/prerna/auth/utils/SecurityModelMetadataUtils.java index 492ee0511e..1ac93867f0 100644 --- a/src/prerna/auth/utils/SecurityModelMetadataUtils.java +++ b/src/prerna/auth/utils/SecurityModelMetadataUtils.java @@ -60,6 +60,7 @@ import prerna.engine.api.IEngine; import prerna.engine.api.IRDBMSEngine; +import prerna.engine.api.ModelCapabilityEnum; import prerna.engine.api.ModelModalityEnum; import prerna.util.ConnectionUtils; import prerna.util.Constants; @@ -81,8 +82,6 @@ public final class SecurityModelMetadataUtils extends AbstractSecurityUtils { private static final Gson LONG_OR_DOUBLE_GSON = new GsonBuilder() .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); - private static final Set CAPABILITIES = Set.of("TEXT_GENERATION", "IMAGE_GENERATION", "VIDEO_GENERATION", - "EMBEDDING", "TRANSCRIPTION", "SPEECH_SYNTHESIS", "RERANKING", "MODERATION"); private static final Set EDITABLE_METADATA_KEYS = Set.of(Constants.MODEL_PROVIDER, Constants.SERVING_PROVIDER, Constants.MODEL_CAPABILITY, Constants.INPUT_MODALITIES, Constants.OUTPUT_MODALITIES, Constants.CONTEXT_WINDOW, Constants.MAX_TOKENS, Constants.BUILTIN_TOOLS, @@ -708,16 +707,13 @@ private static void normalizeCapabilityProperty(Map details) { } capability = capability.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); capability = switch (capability) { - case "CHAT", "LLM" -> "TEXT_GENERATION"; - case "EMBEDDINGS" -> "EMBEDDING"; - case "TTS", "TEXT_TO_SPEECH" -> "SPEECH_SYNTHESIS"; - case "STT", "SPEECH_TO_TEXT" -> "TRANSCRIPTION"; + case "CHAT", "LLM" -> ModelCapabilityEnum.TEXT_GENERATION.name(); + case "EMBEDDINGS" -> ModelCapabilityEnum.EMBEDDING.name(); + case "TTS", "TEXT_TO_SPEECH" -> ModelCapabilityEnum.SPEECH_SYNTHESIS.name(); + case "STT", "SPEECH_TO_TEXT" -> ModelCapabilityEnum.TRANSCRIPTION.name(); default -> capability; }; - if (!CAPABILITIES.contains(capability)) { - throw new IllegalArgumentException("Unsupported model capability " + capability); - } - details.put(Constants.MODEL_CAPABILITY, capability); + details.put(Constants.MODEL_CAPABILITY, ModelCapabilityEnum.fromName(capability).name()); } private static void normalizeListProperty(Map details, String key, boolean modality) { diff --git a/src/prerna/engine/api/ModelCapabilityEnum.java b/src/prerna/engine/api/ModelCapabilityEnum.java new file mode 100644 index 0000000000..ac84d511bc --- /dev/null +++ b/src/prerna/engine/api/ModelCapabilityEnum.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.api; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Capabilities supported by model metadata. + */ +public enum ModelCapabilityEnum { + TEXT_GENERATION, IMAGE_GENERATION, VIDEO_GENERATION, EMBEDDING, TRANSCRIPTION, SPEECH_SYNTHESIS, RERANKING, + MODERATION; + + private static final Set NAMES; + + static { + Set names = new LinkedHashSet<>(); + Arrays.stream(values()).map(ModelCapabilityEnum::name).forEach(names::add); + NAMES = Collections.unmodifiableSet(names); + } + + /** + * Parse a capability name case-insensitively. + * + * @param value capability name + * @return the matching capability + * @throws IllegalArgumentException when the value is null, blank, or unknown + */ + public static ModelCapabilityEnum fromName(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Unsupported model capability " + value); + } + String normalizedValue = value.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); + try { + return valueOf(normalizedValue); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unsupported model capability " + normalizedValue, e); + } + } + + /** + * @return the names accepted in model metadata + */ + public static Set names() { + return NAMES; + } +} diff --git a/src/prerna/reactor/agent/AgentRunner.java b/src/prerna/reactor/agent/AgentRunner.java index 1abe8a9a9d..6f987f06b0 100644 --- a/src/prerna/reactor/agent/AgentRunner.java +++ b/src/prerna/reactor/agent/AgentRunner.java @@ -44,6 +44,7 @@ import prerna.cluster.util.ClusterUtil; import prerna.engine.api.IEngine; import prerna.engine.api.IModelEngine; +import prerna.engine.api.ModelCapabilityEnum; import prerna.engine.impl.model.Room; import prerna.engine.impl.model.RoomUtils; import prerna.engine.impl.model.inferencetracking.ModelInferenceLogsUtils; @@ -336,7 +337,8 @@ private static void validateSelectedModel(Insight insight, String modelId) { Map metadata = SecurityModelMetadataUtils.getModelMetadata(modelId); Object capabilityValue = metadata == null ? null : metadata.get(Constants.MODEL_CAPABILITY); String capability = capabilityValue == null ? null : String.valueOf(capabilityValue).trim(); - if (capability != null && !capability.isEmpty() && !"TEXT_GENERATION".equalsIgnoreCase(capability)) { + if (capability != null && !capability.isEmpty() + && !ModelCapabilityEnum.TEXT_GENERATION.name().equalsIgnoreCase(capability)) { throw new IllegalArgumentException("Model engine was not found or is not accessible"); } } diff --git a/src/prerna/util/StaticModelMetadataCatalog.java b/src/prerna/util/StaticModelMetadataCatalog.java index ef23916013..85775a67f7 100644 --- a/src/prerna/util/StaticModelMetadataCatalog.java +++ b/src/prerna/util/StaticModelMetadataCatalog.java @@ -60,6 +60,7 @@ import com.google.gson.reflect.TypeToken; import prerna.auth.utils.SecurityModelMetadataUtils; +import prerna.engine.api.ModelCapabilityEnum; import prerna.engine.api.ModelModalityEnum; /** @@ -281,25 +282,25 @@ private static String inferCapability(List inputModalities, List return null; } if (containsModality(outputModalities, ModelModalityEnum.VIDEO)) { - return "VIDEO_GENERATION"; + return ModelCapabilityEnum.VIDEO_GENERATION.name(); } if (containsModality(outputModalities, ModelModalityEnum.IMAGE)) { - return "IMAGE_GENERATION"; + return ModelCapabilityEnum.IMAGE_GENERATION.name(); } if (containsModality(outputModalities, ModelModalityEnum.AUDIO)) { - return containsModality(inputModalities, ModelModalityEnum.TEXT) ? "SPEECH_SYNTHESIS" - : "TRANSCRIPTION"; + return containsModality(inputModalities, ModelModalityEnum.TEXT) ? ModelCapabilityEnum.SPEECH_SYNTHESIS.name() + : ModelCapabilityEnum.TRANSCRIPTION.name(); } if (containsModality(outputModalities, ModelModalityEnum.VECTOR)) { - return "EMBEDDING"; + return ModelCapabilityEnum.EMBEDDING.name(); } if (containsModality(outputModalities, ModelModalityEnum.TEXT)) { if (!containsModality(inputModalities, ModelModalityEnum.TEXT) && containsModality(inputModalities, ModelModalityEnum.AUDIO)) { - return "TRANSCRIPTION"; + return ModelCapabilityEnum.TRANSCRIPTION.name(); } if (containsModality(inputModalities, ModelModalityEnum.TEXT) && !placeholderOutputLimit) { - return "TEXT_GENERATION"; + return ModelCapabilityEnum.TEXT_GENERATION.name(); } } return null; diff --git a/test/prerna/engine/api/ModelCapabilityEnumUnitTests.java b/test/prerna/engine/api/ModelCapabilityEnumUnitTests.java new file mode 100644 index 0000000000..6b083bcb43 --- /dev/null +++ b/test/prerna/engine/api/ModelCapabilityEnumUnitTests.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ModelCapabilityEnumUnitTests { + + @Test + void parsesCapabilityNames() { + assertEquals(ModelCapabilityEnum.TEXT_GENERATION, ModelCapabilityEnum.fromName("text-generation")); + assertEquals(ModelCapabilityEnum.EMBEDDING, ModelCapabilityEnum.fromName("EMBEDDING")); + } + + @Test + void exposesMetadataNamesInEnumOrder() { + assertEquals(List.of("TEXT_GENERATION", "IMAGE_GENERATION", "VIDEO_GENERATION", "EMBEDDING", + "TRANSCRIPTION", "SPEECH_SYNTHESIS", "RERANKING", "MODERATION"), + List.copyOf(ModelCapabilityEnum.names())); + } + + @Test + void rejectsUnknownNames() { + assertThrows(IllegalArgumentException.class, () -> ModelCapabilityEnum.fromName("chat_model")); + } +}