From 0a8ad3fa4798651187cce0439712f6c67d85da7f Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 8 May 2025 15:07:20 -0700 Subject: [PATCH 01/12] chore: Convert JSON LDValue into the AiConfig Object --- .../sdk/server/ai/LDAiClient.java | 162 +++++++++++++++++- .../sdk/server/ai/datamodel/AiConfig.java | 45 +++++ .../sdk/server/ai/datamodel/Message.java | 23 +++ .../sdk/server/ai/datamodel/Meta.java | 46 +++++ .../sdk/server/ai/datamodel/Model.java | 39 +++++ .../sdk/server/ai/datamodel/Provider.java | 13 ++ .../sdk/server/ai/LDAiClientTest.java | 60 +++++++ 7 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 6b557f9..90d1019 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -1,12 +1,27 @@ package com.launchdarkly.sdk.server.ai; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.ai.datamodel.AiConfig; +import com.launchdarkly.sdk.server.ai.datamodel.Message; +import com.launchdarkly.sdk.server.ai.datamodel.Meta; +import com.launchdarkly.sdk.server.ai.datamodel.Model; +import com.launchdarkly.sdk.server.ai.datamodel.Provider; +import com.launchdarkly.sdk.server.ai.datamodel.Role; import com.launchdarkly.sdk.server.ai.interfaces.LDAiClientInterface; /** - * The LaunchDarkly AI client. The client is capable of retrieving AI Configs from LaunchDarkly, - * and generating events specific to usage of the AI Config when interacting with model providers. + * The LaunchDarkly AI client. The client is capable of retrieving AI Configs + * from LaunchDarkly, + * and generating events specific to usage of the AI Config when interacting + * with model providers. */ public class LDAiClient implements LDAiClientInterface { private LDClientInterface client; @@ -15,14 +30,151 @@ public class LDAiClient implements LDAiClientInterface { /** * Creates a {@link LDAiClient} * - * @param client LaunchDarkly Java Server SDK + * @param client LaunchDarkly Java Server SDK */ public LDAiClient(LDClientInterface client) { - if(client == null) { - //Error + if (client == null) { + // Error } else { this.client = client; this.logger = client.getLogger(); } } + + /** + * Method to convert the JSON variable into the AiConfig object + * + * Currently, I am doing this in a mutable way, just to verify the logic convert + * LDValue into AiConfig. + * It is possible we need a builder to create immutable version of this for ease + * of use in a later PR. + * + * @param value + * @param key + */ + protected AiConfig parseAiConfig(LDValue value, String key) { + AiConfig result = new AiConfig(); + + try { + // Verify the whole value is a JSON object + if (value == null || value.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("Input to parseAiConfig must be a JSON object"); + } + + // Convert the _meta JSON object into Meta + LDValue valueMeta = value.get("_ldMeta"); + if (valueMeta == LDValue.ofNull() || valueMeta.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("_ldMeta must be a JSON object"); + } + + Meta meta = new Meta(); + // TODO: Do we expect customer calling this to build default value? + // If we do, then some of the values would be null + meta.setEnabled(ldValueNullCheck(valueMeta.get("enabled")).booleanValue()); + meta.setVariationKey(ldValueNullCheck(valueMeta.get("variationKey")).stringValue()); + Optional version = Optional.of(valueMeta.get("version").intValue()); + meta.setVersion(version); + result.setMeta(meta); + + // Convert the optional messages from an JSON array of JSON objects into Message + // Q: Does it even make sense to have 0 messages? + LDValue valueMessages = value.get("messages"); + if (valueMeta == LDValue.ofNull() || valueMessages.getType() != LDValueType.ARRAY) { + throw new AiConfigParseException("messages must be a JSON array"); + } + + List messages = new ArrayList(); + for (LDValue valueMessage : valueMessages.values()) { + if (valueMessage == LDValue.ofNull() || valueMessage.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("individual message must be a JSON object"); + } + + Message message = new Message(); + message.setContent(ldValueNullCheck(valueMessage.get("content")).stringValue()); + // TODO: For absolute safety, we need to check this is one out of the three + // possible enum + message.setRole(Role.valueOf(valueMessage.get("role").stringValue().toUpperCase())); + messages.add(message); + } + result.setMessages(messages); + + // Convert the optional model from an JSON object of with parameters and custom + // into Model + LDValue valueModel = value.get("model"); + if (valueModel == LDValue.ofNull() || valueModel.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("model must be a JSON object"); + } + + Model model = new Model(); + model.setName(ldValueNullCheck(valueModel.get("name")).stringValue()); + + LDValue valueParameters = valueModel.get("parameters"); + if (valueParameters.getType() != LDValueType.NULL) { + if (valueParameters.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("non-null parameters must be a JSON object"); + } + + HashMap parameters = new HashMap<>(); + for (String k : valueParameters.keys()) { + parameters.put(k, valueParameters.get(k)); + } + model.setParameters(parameters); + } else { + // Parameters is optional - so we can just set null and proceed + + // TODO: Mustash validation somewhere + model.setParameters(null); + } + + LDValue valueCustom = valueModel.get("custom"); + if (valueCustom.getType() != LDValueType.NULL) { + if (valueCustom.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("non-null custom must be a JSON object"); + } + + HashMap custom = new HashMap<>(); + for (String k : valueCustom.keys()) { + custom.put(k, valueCustom.get(k)); + } + model.setCustom(custom); + } else { + // Custom is optional - we can just set null and proceed + model.setCustom(null); + } + result.setModel(model); + + // Convert the optional provider from an JSON object of with name into Provider + LDValue valueProvider = value.get("provider"); + if (valueProvider.getType() != LDValueType.NULL) { + if (valueProvider.getType() != LDValueType.OBJECT) { + throw new AiConfigParseException("non-null provider must be a JSON object"); + } + + Provider provider = new Provider(); + provider.setName(ldValueNullCheck(valueProvider.get("name")).stringValue()); + result.setProvider(provider); + } else { + // Provider is optional - we can just set null and proceed + result.setProvider(null); + } + } catch (AiConfigParseException e) { + // logger.error(e.getMessage()); + return null; + } + + return result; + } + + protected T ldValueNullCheck(T ldValue) throws AiConfigParseException { + if (ldValue == LDValue.ofNull()) { + throw new AiConfigParseException("Unexpected Null value for non-optional field"); + } + return ldValue; + } + + class AiConfigParseException extends Exception { + AiConfigParseException(String exceptionMessage) { + super(exceptionMessage); + } + } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java new file mode 100644 index 0000000..62d96cb --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java @@ -0,0 +1,45 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.List; + +public class AiConfig { + private List messages; + + private Meta meta; + + private Model model; + + private Provider provider; + + public List getMessages() { + return messages; + } + + public void setMessages(List messages) { + this.messages = messages; + } + + public Meta getMeta() { + return meta; + } + + public void setMeta(Meta meta) { + this.meta = meta; + } + + public Model getModel() { + return model; + } + + public void setModel(Model model) { + this.model = model; + } + + public Provider getProvider() { + return provider; + } + + public void setProvider(Provider provider) { + this.provider = provider; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java new file mode 100644 index 0000000..4747e5b --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java @@ -0,0 +1,23 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +public class Message { + private String content; + + private Role role; + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java new file mode 100644 index 0000000..a686ed0 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java @@ -0,0 +1,46 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.Optional; + +public class Meta { + /** + * The variation key. + */ + private String variationKey; + + /** + * The variation version. + */ + private Optional version; + + /** + * If the config is enabled. + */ + private boolean enabled; + + // Getters and Setters + + public String getVariationKey() { + return variationKey; + } + + public void setVariationKey(String variationKey) { + this.variationKey = variationKey; + } + + public Optional getVersion() { + return version; + } + + public void setVersion(Optional version) { + this.version = version; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java new file mode 100644 index 0000000..05f447d --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.HashMap; + +import com.launchdarkly.sdk.LDValue; + +public class Model { + private String name; + + private HashMap parameters; + + private HashMap custom; + + // Getters and Setters + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public HashMap getParameters() { + return parameters; + } + + public void setParameters(HashMap parameters) { + this.parameters = parameters; + } + + public HashMap getCustom() { + return custom; + } + + public void setCustom(HashMap custom) { + this.custom = custom; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java new file mode 100644 index 0000000..c38324b --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java @@ -0,0 +1,13 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +public class Provider { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java new file mode 100644 index 0000000..2c60fab --- /dev/null +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java @@ -0,0 +1,60 @@ +package com.launchdarkly.sdk.server.ai; + +import org.junit.Test; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.ai.datamodel.AiConfig; +import com.launchdarkly.sdk.server.ai.datamodel.Role; + +import static org.junit.Assert.assertEquals; + +public class LDAiClientTest { + @Test + public void testParseAiConfig() { + String rawJson = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : 1234,\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [\n" + // + " {\n" + // + " \"content\": \"This is an {{ adjective }} message.\",\n" + // + " \"role\": \"user\"\n" + // + " },\n" + // + " {\n" + // + " \"content\": \"{{ greeting}}, this is another message!\",\n" + // + " \"role\": \"system\"\n" + // + " },\n" + // + " {\n" + // + " \"content\": \"This is the final {{ noun }}.\",\n" + // + " \"role\": \"assistant\"\n" + // + " }\n" + // + " ],\n" + // + " \"model\": {\n" + // + " \"name\": \"my-cool-custom-model\",\n" + // + " \"parameters\": {\n" + // + " \"foo\" : \"bar\",\n" + // + " \"baz\" : 23,\n" + // + " \"qux\" : true,\n" + // + " \"whatever\" : [],\n" + // + " \"another\" : {}\n" + // + " }\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(rawJson); + + LDAiClient client = new LDAiClient(null); + + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertEquals(Role.USER, result.getMessages().get(0).getRole()); + assertEquals(Integer.valueOf(1), result.getMeta().getVersion().orElse(0)); + assertEquals(LDValue.of(true), result.getModel().getParameters().get("qux")); + assertEquals("provider-name", result.getProvider().getName()); + }; +} From 28947f10c43b8e80c42e7c1ab247d80c3e6998e1 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 15 May 2025 15:08:00 -0700 Subject: [PATCH 02/12] chore: add github workflow for server ai --- .github/workflows/java-server-sdk-ai.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/java-server-sdk-ai.yml diff --git a/.github/workflows/java-server-sdk-ai.yml b/.github/workflows/java-server-sdk-ai.yml new file mode 100644 index 0000000..88fe22b --- /dev/null +++ b/.github/workflows/java-server-sdk-ai.yml @@ -0,0 +1,23 @@ +name: java-server-sdk-ai + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-java-server-sdk-ai: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Shared CI Steps + uses: ./.github/actions/ci + with: + workspace_path: 'lib/sdk/server-ai' + java_version: 8 From f83ba9402bbc873b021f756f1581c5cb280781af Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 15 May 2025 15:16:40 -0700 Subject: [PATCH 03/12] chore: first step to make the datamodel types immutable --- .../launchdarkly/sdk/server/ai/LDAiClient.java | 11 +++-------- .../sdk/server/ai/datamodel/AiConfig.java | 2 +- .../sdk/server/ai/datamodel/Message.java | 15 ++++++--------- .../sdk/server/ai/datamodel/Meta.java | 2 +- .../sdk/server/ai/datamodel/Model.java | 2 +- .../sdk/server/ai/datamodel/Provider.java | 10 +++++----- 6 files changed, 17 insertions(+), 25 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 90d1019..97a4214 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -23,7 +23,7 @@ * and generating events specific to usage of the AI Config when interacting * with model providers. */ -public class LDAiClient implements LDAiClientInterface { +public final class LDAiClient implements LDAiClientInterface { private LDClientInterface client; private LDLogger logger; @@ -89,11 +89,7 @@ protected AiConfig parseAiConfig(LDValue value, String key) { throw new AiConfigParseException("individual message must be a JSON object"); } - Message message = new Message(); - message.setContent(ldValueNullCheck(valueMessage.get("content")).stringValue()); - // TODO: For absolute safety, we need to check this is one out of the three - // possible enum - message.setRole(Role.valueOf(valueMessage.get("role").stringValue().toUpperCase())); + Message message = new Message(ldValueNullCheck(valueMessage.get("content")).stringValue(), Role.valueOf(valueMessage.get("role").stringValue().toUpperCase())); messages.add(message); } result.setMessages(messages); @@ -150,8 +146,7 @@ protected AiConfig parseAiConfig(LDValue value, String key) { throw new AiConfigParseException("non-null provider must be a JSON object"); } - Provider provider = new Provider(); - provider.setName(ldValueNullCheck(valueProvider.get("name")).stringValue()); + Provider provider = new Provider(ldValueNullCheck(valueProvider.get("name")).stringValue()); result.setProvider(provider); } else { // Provider is optional - we can just set null and proceed diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java index 62d96cb..d85b6fd 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java @@ -2,7 +2,7 @@ import java.util.List; -public class AiConfig { +public final class AiConfig { private List messages; private Meta meta; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java index 4747e5b..2b7a00a 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java @@ -1,23 +1,20 @@ package com.launchdarkly.sdk.server.ai.datamodel; -public class Message { +public final class Message { private String content; private Role role; - public String getContent() { - return content; + public Message(String content, Role role) { + this.content = content; + this.role = role; } - public void setContent(String content) { - this.content = content; + public String getContent() { + return content; } public Role getRole() { return role; } - - public void setRole(Role role) { - this.role = role; - } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java index a686ed0..f6488a2 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java @@ -2,7 +2,7 @@ import java.util.Optional; -public class Meta { +public final class Meta { /** * The variation key. */ diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java index 05f447d..3cc2163 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java @@ -4,7 +4,7 @@ import com.launchdarkly.sdk.LDValue; -public class Model { +public final class Model { private String name; private HashMap parameters; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java index c38324b..6b35fa1 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java @@ -1,13 +1,13 @@ package com.launchdarkly.sdk.server.ai.datamodel; -public class Provider { +public final class Provider { private String name; - public String getName() { - return name; + public Provider(String name) { + this.name = name; } - public void setName(String name) { - this.name = name; + public String getName() { + return name; } } From 877d4e288df53540267ccbc8626ff8f9fc3a2611 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 15 May 2025 15:19:20 -0700 Subject: [PATCH 04/12] chore: temporary enable build --- .github/workflows/java-server-sdk-ai.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/java-server-sdk-ai.yml b/.github/workflows/java-server-sdk-ai.yml index 88fe22b..1462e3d 100644 --- a/.github/workflows/java-server-sdk-ai.yml +++ b/.github/workflows/java-server-sdk-ai.yml @@ -6,7 +6,7 @@ on: paths-ignore: - '**.md' #Do not need to run CI for markdown changes. pull_request: - branches: [main, 'feat/**'] + branches: [main, 'feat/**', 'lc/**'] paths-ignore: - '**.md' From 90f34b9ef352f1bac572a9bc71a8f97fb57303bf Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 15 May 2025 16:27:05 -0700 Subject: [PATCH 05/12] chore: using the valuesAs converter for messages --- .../sdk/server/ai/LDAiClient.java | 8 +-- .../sdk/server/ai/datamodel/Message.java | 17 +++++ .../sdk/server/ai/datamodel/Role.java | 30 +++++++- .../sdk/server/ai/LDAiClientTest.java | 71 ++++++++++--------- 4 files changed, 82 insertions(+), 44 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 97a4214..befa7de 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -84,12 +84,8 @@ protected AiConfig parseAiConfig(LDValue value, String key) { } List messages = new ArrayList(); - for (LDValue valueMessage : valueMessages.values()) { - if (valueMessage == LDValue.ofNull() || valueMessage.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("individual message must be a JSON object"); - } - - Message message = new Message(ldValueNullCheck(valueMessage.get("content")).stringValue(), Role.valueOf(valueMessage.get("role").stringValue().toUpperCase())); + valueMessages.valuesAs(new Message.MessageConverter()); + for (Message message : valueMessages.valuesAs(new Message.MessageConverter())) { messages.add(message); } result.setMessages(messages); diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java index 2b7a00a..eb0aee9 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java @@ -1,6 +1,23 @@ package com.launchdarkly.sdk.server.ai.datamodel; +import com.launchdarkly.sdk.LDValue; + public final class Message { + public static class MessageConverter extends LDValue.Converter { + @Override + public LDValue fromType(Message message) { + return LDValue.buildObject() + .put("content", message.getContent()) + .put("role", message.getRole().toString()) + .build(); + } + + @Override + public Message toType(LDValue ldValue) { + return new Message(ldValue.get("content").stringValue(), Role.getRole(ldValue.get("role").stringValue())); + } + } + private String content; private Role role; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Role.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Role.java index 524cac3..fd7f4aa 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Role.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Role.java @@ -7,13 +7,37 @@ public enum Role { /** * User Role */ - USER, + USER("user"), /** * System Role */ - SYSTEM, + SYSTEM("system"), /** * Assistant Role */ - ASSISTANT + ASSISTANT("assistant"); + + private final String role; + + private Role(String role) { + this.role = role; + } + + public static Role getRole(String role) { + switch (role) { + case "user": + return USER; + case "system": + return SYSTEM; + case "assistant": + return ASSISTANT; + default: + return null; + } + } + + @Override + public String toString() { + return role; + } } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java index 2c60fab..ffa6a1a 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java @@ -9,50 +9,51 @@ import static org.junit.Assert.assertEquals; public class LDAiClientTest { - @Test + @Test public void testParseAiConfig() { String rawJson = "{\n" + // - " \"_ldMeta\": {\n" + // - " \"variationKey\" : 1234,\n" + // - " \"enabled\": true,\n" + // - " \"version\": 1\n" + // - " },\n" + // - " \"messages\": [\n" + // - " {\n" + // - " \"content\": \"This is an {{ adjective }} message.\",\n" + // - " \"role\": \"user\"\n" + // - " },\n" + // - " {\n" + // - " \"content\": \"{{ greeting}}, this is another message!\",\n" + // - " \"role\": \"system\"\n" + // - " },\n" + // - " {\n" + // - " \"content\": \"This is the final {{ noun }}.\",\n" + // - " \"role\": \"assistant\"\n" + // - " }\n" + // - " ],\n" + // - " \"model\": {\n" + // - " \"name\": \"my-cool-custom-model\",\n" + // - " \"parameters\": {\n" + // - " \"foo\" : \"bar\",\n" + // - " \"baz\" : 23,\n" + // - " \"qux\" : true,\n" + // - " \"whatever\" : [],\n" + // - " \"another\" : {}\n" + // - " }\n" + // - " },\n" + // - " \"provider\": {\n" + // - " \"name\" : \"provider-name\"\n" + // - " }\n" + // - "}"; + " \"_ldMeta\": {\n" + // + " \"variationKey\" : 1234,\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [\n" + // + " {\n" + // + " \"content\": \"This is an {{ adjective }} message.\",\n" + // + " \"role\": \"user\"\n" + // + " },\n" + // + " {\n" + // + " \"content\": \"{{ greeting}}, this is another message!\",\n" + // + " \"role\": \"system\"\n" + // + " },\n" + // + " {\n" + // + " \"content\": \"This is the final {{ noun }}.\",\n" + // + " \"role\": \"assistant\"\n" + // + " }\n" + // + " ],\n" + // + " \"model\": {\n" + // + " \"name\": \"my-cool-custom-model\",\n" + // + " \"parameters\": {\n" + // + " \"foo\" : \"bar\",\n" + // + " \"baz\" : 23,\n" + // + " \"qux\" : true,\n" + // + " \"whatever\" : [],\n" + // + " \"another\" : {}\n" + // + " }\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; LDValue input = LDValue.parse(rawJson); - + LDAiClient client = new LDAiClient(null); AiConfig result = client.parseAiConfig(input, "Whatever"); assertEquals(Role.USER, result.getMessages().get(0).getRole()); + assertEquals("This is the final {{ noun }}.", result.getMessages().get(2).getContent()); assertEquals(Integer.valueOf(1), result.getMeta().getVersion().orElse(0)); assertEquals(LDValue.of(true), result.getModel().getParameters().get("qux")); assertEquals("provider-name", result.getProvider().getName()); From ded3e630282e8a9c0659edb594898f477ffaa27d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 23:52:29 +0000 Subject: [PATCH 06/12] chore: update meta and model to be immutable Co-Authored-By: lchan@launchdarkly.com --- .../sdk/server/ai/LDAiClient.java | 41 +++++++------------ .../sdk/server/ai/datamodel/Meta.java | 31 +++++++------- .../sdk/server/ai/datamodel/Model.java | 37 +++++++++-------- 3 files changed, 49 insertions(+), 60 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index befa7de..66a861f 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -44,11 +44,6 @@ public LDAiClient(LDClientInterface client) { /** * Method to convert the JSON variable into the AiConfig object * - * Currently, I am doing this in a mutable way, just to verify the logic convert - * LDValue into AiConfig. - * It is possible we need a builder to create immutable version of this for ease - * of use in a later PR. - * * @param value * @param key */ @@ -67,13 +62,12 @@ protected AiConfig parseAiConfig(LDValue value, String key) { throw new AiConfigParseException("_ldMeta must be a JSON object"); } - Meta meta = new Meta(); - // TODO: Do we expect customer calling this to build default value? - // If we do, then some of the values would be null - meta.setEnabled(ldValueNullCheck(valueMeta.get("enabled")).booleanValue()); - meta.setVariationKey(ldValueNullCheck(valueMeta.get("variationKey")).stringValue()); - Optional version = Optional.of(valueMeta.get("version").intValue()); - meta.setVersion(version); + // Create Meta using constructor + Meta meta = new Meta( + ldValueNullCheck(valueMeta.get("variationKey")).stringValue(), + Optional.of(valueMeta.get("version").intValue()), + ldValueNullCheck(valueMeta.get("enabled")).booleanValue() + ); result.setMeta(meta); // Convert the optional messages from an JSON array of JSON objects into Message @@ -97,8 +91,10 @@ protected AiConfig parseAiConfig(LDValue value, String key) { throw new AiConfigParseException("model must be a JSON object"); } - Model model = new Model(); - model.setName(ldValueNullCheck(valueModel.get("name")).stringValue()); + // Prepare parameters and custom maps for Model + String modelName = ldValueNullCheck(valueModel.get("name")).stringValue(); + HashMap parameters = null; + HashMap custom = null; LDValue valueParameters = valueModel.get("parameters"); if (valueParameters.getType() != LDValueType.NULL) { @@ -106,16 +102,10 @@ protected AiConfig parseAiConfig(LDValue value, String key) { throw new AiConfigParseException("non-null parameters must be a JSON object"); } - HashMap parameters = new HashMap<>(); + parameters = new HashMap<>(); for (String k : valueParameters.keys()) { parameters.put(k, valueParameters.get(k)); } - model.setParameters(parameters); - } else { - // Parameters is optional - so we can just set null and proceed - - // TODO: Mustash validation somewhere - model.setParameters(null); } LDValue valueCustom = valueModel.get("custom"); @@ -124,15 +114,14 @@ protected AiConfig parseAiConfig(LDValue value, String key) { throw new AiConfigParseException("non-null custom must be a JSON object"); } - HashMap custom = new HashMap<>(); + custom = new HashMap<>(); for (String k : valueCustom.keys()) { custom.put(k, valueCustom.get(k)); } - model.setCustom(custom); - } else { - // Custom is optional - we can just set null and proceed - model.setCustom(null); } + + // Create Model using constructor + Model model = new Model(modelName, parameters, custom); result.setModel(model); // Convert the optional provider from an JSON object of with name into Provider diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java index f6488a2..952df47 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java @@ -6,41 +6,40 @@ public final class Meta { /** * The variation key. */ - private String variationKey; + private final String variationKey; /** * The variation version. */ - private Optional version; + private final Optional version; /** * If the config is enabled. */ - private boolean enabled; + private final boolean enabled; - // Getters and Setters + /** + * Constructor for Meta with all required fields. + * + * @param variationKey the variation key + * @param version the version + * @param enabled if the config is enabled + */ + public Meta(String variationKey, Optional version, boolean enabled) { + this.variationKey = variationKey; + this.version = version != null ? version : Optional.empty(); + this.enabled = enabled; + } public String getVariationKey() { return variationKey; } - public void setVariationKey(String variationKey) { - this.variationKey = variationKey; - } - public Optional getVersion() { return version; } - public void setVersion(Optional version) { - this.version = version; - } - public boolean isEnabled() { return enabled; } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java index 3cc2163..b712211 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java @@ -1,39 +1,40 @@ package com.launchdarkly.sdk.server.ai.datamodel; +import java.util.Collections; import java.util.HashMap; +import java.util.Map; import com.launchdarkly.sdk.LDValue; public final class Model { - private String name; + private final String name; - private HashMap parameters; + private final Map parameters; - private HashMap custom; + private final Map custom; - // Getters and Setters + /** + * Constructor for Model with all required fields. + * + * @param name the model name + * @param parameters the parameters map + * @param custom the custom map + */ + public Model(String name, Map parameters, Map custom) { + this.name = name; + this.parameters = parameters != null ? Collections.unmodifiableMap(new HashMap<>(parameters)) : Collections.emptyMap(); + this.custom = custom != null ? Collections.unmodifiableMap(new HashMap<>(custom)) : Collections.emptyMap(); + } public String getName() { return name; } - public void setName(String name) { - this.name = name; - } - - public HashMap getParameters() { + public Map getParameters() { return parameters; } - public void setParameters(HashMap parameters) { - this.parameters = parameters; - } - - public HashMap getCustom() { + public Map getCustom() { return custom; } - - public void setCustom(HashMap custom) { - this.custom = custom; - } } From ba1597abd007ee498ed7f79a7caab564c67d3405 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 19 May 2025 14:18:46 -0700 Subject: [PATCH 07/12] chore: Address code review comments with type guards --- .../sdk/server/ai/LDAiClient.java | 173 +++++++++--------- .../sdk/server/ai/datamodel/AiConfig.java | 38 ++-- .../sdk/server/ai/datamodel/Meta.java | 13 +- .../sdk/server/ai/LDAiClientTest.java | 2 +- 4 files changed, 113 insertions(+), 113 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 66a861f..34d3d4c 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.ai; +import static java.util.Arrays.binarySearch; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -43,113 +45,114 @@ public LDAiClient(LDClientInterface client) { /** * Method to convert the JSON variable into the AiConfig object + * + * If the parsing failed, the code will log an error and + * return a well formed but with nullable value nulled and disabled AIConfig + * + * Doing all the error checks, so if somehow LD backend return incorrect value types, there is logging + * This also opens up the possibility of allowing customer to build this using a JSON string in the future * * @param value * @param key */ protected AiConfig parseAiConfig(LDValue value, String key) { - AiConfig result = new AiConfig(); - - try { - // Verify the whole value is a JSON object - if (value == null || value.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("Input to parseAiConfig must be a JSON object"); - } - - // Convert the _meta JSON object into Meta - LDValue valueMeta = value.get("_ldMeta"); - if (valueMeta == LDValue.ofNull() || valueMeta.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("_ldMeta must be a JSON object"); - } + boolean enabled = false; - // Create Meta using constructor - Meta meta = new Meta( - ldValueNullCheck(valueMeta.get("variationKey")).stringValue(), - Optional.of(valueMeta.get("version").intValue()), - ldValueNullCheck(valueMeta.get("enabled")).booleanValue() - ); - result.setMeta(meta); - - // Convert the optional messages from an JSON array of JSON objects into Message - // Q: Does it even make sense to have 0 messages? - LDValue valueMessages = value.get("messages"); - if (valueMeta == LDValue.ofNull() || valueMessages.getType() != LDValueType.ARRAY) { - throw new AiConfigParseException("messages must be a JSON array"); - } - - List messages = new ArrayList(); - valueMessages.valuesAs(new Message.MessageConverter()); - for (Message message : valueMessages.valuesAs(new Message.MessageConverter())) { - messages.add(message); - } - result.setMessages(messages); + // Verify the whole value is a JSON object + if(!checkValueWithFailureLogging(value, LDValueType.OBJECT, logger, "Input to parseAiConfig must be a JSON object")) { + return new AiConfig(enabled, null, null, null, null); + } - // Convert the optional model from an JSON object of with parameters and custom - // into Model - LDValue valueModel = value.get("model"); - if (valueModel == LDValue.ofNull() || valueModel.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("model must be a JSON object"); - } + // Convert the _meta JSON object into Meta + LDValue valueMeta = value.get("_ldMeta"); + if (!checkValueWithFailureLogging(valueMeta, LDValueType.OBJECT, logger, "_ldMeta must be a JSON object")) { + // Q: If we can't read _meta, enabled by spec would be defaulted to false. Does it even matter the rest of the values? + return new AiConfig(enabled, null, null, null, null); + } - // Prepare parameters and custom maps for Model - String modelName = ldValueNullCheck(valueModel.get("name")).stringValue(); - HashMap parameters = null; - HashMap custom = null; + // The booleanValue will get false if that value something that we are not expecting, which is good + enabled = valueMeta.get("enabled").booleanValue(); - LDValue valueParameters = valueModel.get("parameters"); - if (valueParameters.getType() != LDValueType.NULL) { - if (valueParameters.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("non-null parameters must be a JSON object"); + String variationKey = null; + if (checkValueWithFailureLogging(valueMeta.get("variationKey"), LDValueType.STRING, logger, "variationKey should be a string")) { + variationKey = valueMeta.get("variationKey").stringValue(); + } + // Create Meta using constructor + Meta meta = new Meta( + variationKey, + Optional.of(valueMeta.get("version").intValue()) + ); + + // Convert the optional model from an JSON object of with parameters and custom + // into Model + Model model = null; + + LDValue valueModel = value.get("model"); + if (checkValueWithFailureLogging(valueModel, LDValueType.OBJECT, logger, "model if exists must be a JSON object")) { + if (checkValueWithFailureLogging(valueModel.get("name"), LDValueType.STRING, logger, "model name must be a string and is required")) { + String modelName = valueModel.get("name").stringValue(); + + // Prepare parameters and custom maps for Model + HashMap parameters = null; + HashMap custom = null; + + LDValue valueParameters = valueModel.get("parameters"); + if (checkValueWithFailureLogging(valueParameters, LDValueType.OBJECT, logger, "non-null parameters must be a JSON object")) { + parameters = new HashMap<>(); + for (String k : valueParameters.keys()) { + parameters.put(k, valueParameters.get(k)); + } } - - parameters = new HashMap<>(); - for (String k : valueParameters.keys()) { - parameters.put(k, valueParameters.get(k)); + + LDValue valueCustom = valueModel.get("custom"); + if (checkValueWithFailureLogging(valueCustom, LDValueType.OBJECT, logger, "non-null custom must be a JSON object")) { + + custom = new HashMap<>(); + for (String k : valueCustom.keys()) { + custom.put(k, valueCustom.get(k)); + } } + + model = new Model(modelName, parameters, custom); } + } - LDValue valueCustom = valueModel.get("custom"); - if (valueCustom.getType() != LDValueType.NULL) { - if (valueCustom.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("non-null custom must be a JSON object"); - } + // Convert the optional messages from an JSON array of JSON objects into Message + // Q: Does it even make sense to have 0 messages? + List messages = null; - custom = new HashMap<>(); - for (String k : valueCustom.keys()) { - custom.put(k, valueCustom.get(k)); - } + LDValue valueMessages = value.get("messages"); + if (checkValueWithFailureLogging(valueMessages, LDValueType.ARRAY, logger, "messages if exists must be a JSON array")) { + messages = new ArrayList(); + valueMessages.valuesAs(new Message.MessageConverter()); + for (Message message : valueMessages.valuesAs(new Message.MessageConverter())) { + messages.add(message); } - - // Create Model using constructor - Model model = new Model(modelName, parameters, custom); - result.setModel(model); - - // Convert the optional provider from an JSON object of with name into Provider - LDValue valueProvider = value.get("provider"); - if (valueProvider.getType() != LDValueType.NULL) { - if (valueProvider.getType() != LDValueType.OBJECT) { - throw new AiConfigParseException("non-null provider must be a JSON object"); - } + } - Provider provider = new Provider(ldValueNullCheck(valueProvider.get("name")).stringValue()); - result.setProvider(provider); - } else { - // Provider is optional - we can just set null and proceed - result.setProvider(null); + // Convert the optional provider from an JSON object of with name into Provider + LDValue valueProvider = value.get("provider"); + String providerName = null; + + if(checkValueWithFailureLogging(valueProvider, LDValueType.OBJECT, logger, "non-null provider must be a JSON object")) { + if(checkValueWithFailureLogging(valueProvider.get("name"), LDValueType.STRING, logger, "provider name must be a String")) { + providerName = valueProvider.get("name").stringValue(); } - } catch (AiConfigParseException e) { - // logger.error(e.getMessage()); - return null; } - return result; + Provider provider = new Provider(providerName); + + return new AiConfig(enabled, meta, model, messages, provider); } - protected T ldValueNullCheck(T ldValue) throws AiConfigParseException { - if (ldValue == LDValue.ofNull()) { - throw new AiConfigParseException("Unexpected Null value for non-optional field"); + protected boolean checkValueWithFailureLogging(LDValue ldValue, LDValueType expectedType, LDLogger logger, String message) { + if (ldValue.getType() != expectedType) { + if (logger != null) { + logger.error(message); + } + return false; } - return ldValue; + return true; } class AiConfigParseException extends Exception { diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java index d85b6fd..c006114 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java @@ -3,43 +3,41 @@ import java.util.List; public final class AiConfig { - private List messages; + private final boolean enabled; - private Meta meta; + private final Meta meta; + + private final Model model; + + private final List messages; - private Model model; + private final Provider provider; - private Provider provider; + public AiConfig(boolean enabled, Meta meta, Model model, List messages, Provider provider) { + this.enabled = enabled; + this.meta = meta; + this.model = model; + this.messages = messages; + this.provider = provider; + } - public List getMessages() { - return messages; + public boolean isEnabled() { + return enabled; } - public void setMessages(List messages) { - this.messages = messages; + public List getMessages() { + return messages; } public Meta getMeta() { return meta; } - public void setMeta(Meta meta) { - this.meta = meta; - } - public Model getModel() { return model; } - public void setModel(Model model) { - this.model = model; - } - public Provider getProvider() { return provider; } - - public void setProvider(Provider provider) { - this.provider = provider; - } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java index 952df47..32322c1 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java @@ -16,19 +16,18 @@ public final class Meta { /** * If the config is enabled. */ - private final boolean enabled; + // private final boolean enabled; /** * Constructor for Meta with all required fields. * * @param variationKey the variation key * @param version the version - * @param enabled if the config is enabled */ - public Meta(String variationKey, Optional version, boolean enabled) { + public Meta(String variationKey, Optional version) { this.variationKey = variationKey; this.version = version != null ? version : Optional.empty(); - this.enabled = enabled; + // this.enabled = enabled; } public String getVariationKey() { @@ -39,7 +38,7 @@ public Optional getVersion() { return version; } - public boolean isEnabled() { - return enabled; - } + // public boolean isEnabled() { + // return enabled; + // } } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java index ffa6a1a..6520924 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java @@ -13,7 +13,7 @@ public class LDAiClientTest { public void testParseAiConfig() { String rawJson = "{\n" + // " \"_ldMeta\": {\n" + // - " \"variationKey\" : 1234,\n" + // + " \"variationKey\" : \"1234\",\n" + // " \"enabled\": true,\n" + // " \"version\": 1\n" + // " },\n" + // From 5298fadeab33382d4c14e4e724e15461c7e444d5 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 19 May 2025 14:23:12 -0700 Subject: [PATCH 08/12] chore: update formatting --- .../sdk/server/ai/LDAiClient.java | 54 +++++++++++-------- .../sdk/server/ai/datamodel/AiConfig.java | 4 +- .../sdk/server/ai/datamodel/Meta.java | 13 ++--- .../sdk/server/ai/datamodel/Model.java | 7 +-- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 34d3d4c..6ae89d2 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.server.ai; -import static java.util.Arrays.binarySearch; - import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -16,7 +14,6 @@ import com.launchdarkly.sdk.server.ai.datamodel.Meta; import com.launchdarkly.sdk.server.ai.datamodel.Model; import com.launchdarkly.sdk.server.ai.datamodel.Provider; -import com.launchdarkly.sdk.server.ai.datamodel.Role; import com.launchdarkly.sdk.server.ai.interfaces.LDAiClientInterface; /** @@ -46,11 +43,13 @@ public LDAiClient(LDClientInterface client) { /** * Method to convert the JSON variable into the AiConfig object * - * If the parsing failed, the code will log an error and + * If the parsing failed, the code will log an error and * return a well formed but with nullable value nulled and disabled AIConfig * - * Doing all the error checks, so if somehow LD backend return incorrect value types, there is logging - * This also opens up the possibility of allowing customer to build this using a JSON string in the future + * Doing all the error checks, so if somehow LD backend return incorrect value + * types, there is logging + * This also opens up the possibility of allowing customer to build this using a + * JSON string in the future * * @param value * @param key @@ -59,37 +58,42 @@ protected AiConfig parseAiConfig(LDValue value, String key) { boolean enabled = false; // Verify the whole value is a JSON object - if(!checkValueWithFailureLogging(value, LDValueType.OBJECT, logger, "Input to parseAiConfig must be a JSON object")) { + if (!checkValueWithFailureLogging(value, LDValueType.OBJECT, logger, + "Input to parseAiConfig must be a JSON object")) { return new AiConfig(enabled, null, null, null, null); } // Convert the _meta JSON object into Meta LDValue valueMeta = value.get("_ldMeta"); if (!checkValueWithFailureLogging(valueMeta, LDValueType.OBJECT, logger, "_ldMeta must be a JSON object")) { - // Q: If we can't read _meta, enabled by spec would be defaulted to false. Does it even matter the rest of the values? + // Q: If we can't read _meta, enabled by spec would be defaulted to false. Does + // it even matter the rest of the values? return new AiConfig(enabled, null, null, null, null); } - // The booleanValue will get false if that value something that we are not expecting, which is good + // The booleanValue will get false if that value something that we are not + // expecting, which is good enabled = valueMeta.get("enabled").booleanValue(); String variationKey = null; - if (checkValueWithFailureLogging(valueMeta.get("variationKey"), LDValueType.STRING, logger, "variationKey should be a string")) { + if (checkValueWithFailureLogging(valueMeta.get("variationKey"), LDValueType.STRING, logger, + "variationKey should be a string")) { variationKey = valueMeta.get("variationKey").stringValue(); } // Create Meta using constructor Meta meta = new Meta( - variationKey, - Optional.of(valueMeta.get("version").intValue()) - ); + variationKey, + Optional.of(valueMeta.get("version").intValue())); // Convert the optional model from an JSON object of with parameters and custom // into Model Model model = null; LDValue valueModel = value.get("model"); - if (checkValueWithFailureLogging(valueModel, LDValueType.OBJECT, logger, "model if exists must be a JSON object")) { - if (checkValueWithFailureLogging(valueModel.get("name"), LDValueType.STRING, logger, "model name must be a string and is required")) { + if (checkValueWithFailureLogging(valueModel, LDValueType.OBJECT, logger, + "model if exists must be a JSON object")) { + if (checkValueWithFailureLogging(valueModel.get("name"), LDValueType.STRING, logger, + "model name must be a string and is required")) { String modelName = valueModel.get("name").stringValue(); // Prepare parameters and custom maps for Model @@ -97,15 +101,17 @@ protected AiConfig parseAiConfig(LDValue value, String key) { HashMap custom = null; LDValue valueParameters = valueModel.get("parameters"); - if (checkValueWithFailureLogging(valueParameters, LDValueType.OBJECT, logger, "non-null parameters must be a JSON object")) { + if (checkValueWithFailureLogging(valueParameters, LDValueType.OBJECT, logger, + "non-null parameters must be a JSON object")) { parameters = new HashMap<>(); for (String k : valueParameters.keys()) { parameters.put(k, valueParameters.get(k)); } } - + LDValue valueCustom = valueModel.get("custom"); - if (checkValueWithFailureLogging(valueCustom, LDValueType.OBJECT, logger, "non-null custom must be a JSON object")) { + if (checkValueWithFailureLogging(valueCustom, LDValueType.OBJECT, logger, + "non-null custom must be a JSON object")) { custom = new HashMap<>(); for (String k : valueCustom.keys()) { @@ -122,7 +128,8 @@ protected AiConfig parseAiConfig(LDValue value, String key) { List messages = null; LDValue valueMessages = value.get("messages"); - if (checkValueWithFailureLogging(valueMessages, LDValueType.ARRAY, logger, "messages if exists must be a JSON array")) { + if (checkValueWithFailureLogging(valueMessages, LDValueType.ARRAY, logger, + "messages if exists must be a JSON array")) { messages = new ArrayList(); valueMessages.valuesAs(new Message.MessageConverter()); for (Message message : valueMessages.valuesAs(new Message.MessageConverter())) { @@ -134,8 +141,10 @@ protected AiConfig parseAiConfig(LDValue value, String key) { LDValue valueProvider = value.get("provider"); String providerName = null; - if(checkValueWithFailureLogging(valueProvider, LDValueType.OBJECT, logger, "non-null provider must be a JSON object")) { - if(checkValueWithFailureLogging(valueProvider.get("name"), LDValueType.STRING, logger, "provider name must be a String")) { + if (checkValueWithFailureLogging(valueProvider, LDValueType.OBJECT, logger, + "non-null provider must be a JSON object")) { + if (checkValueWithFailureLogging(valueProvider.get("name"), LDValueType.STRING, logger, + "provider name must be a String")) { providerName = valueProvider.get("name").stringValue(); } } @@ -145,7 +154,8 @@ protected AiConfig parseAiConfig(LDValue value, String key) { return new AiConfig(enabled, meta, model, messages, provider); } - protected boolean checkValueWithFailureLogging(LDValue ldValue, LDValueType expectedType, LDLogger logger, String message) { + protected boolean checkValueWithFailureLogging(LDValue ldValue, LDValueType expectedType, LDLogger logger, + String message) { if (ldValue.getType() != expectedType) { if (logger != null) { logger.error(message); diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java index c006114..a85026e 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java @@ -6,9 +6,9 @@ public final class AiConfig { private final boolean enabled; private final Meta meta; - + private final Model model; - + private final List messages; private final Provider provider; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java index 32322c1..b50468b 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java @@ -13,21 +13,18 @@ public final class Meta { */ private final Optional version; - /** - * If the config is enabled. - */ - // private final boolean enabled; + // The enable paramter is taken outside of the Meta to be a top level value of + // AiConfig /** * Constructor for Meta with all required fields. * * @param variationKey the variation key - * @param version the version + * @param version the version */ public Meta(String variationKey, Optional version) { this.variationKey = variationKey; this.version = version != null ? version : Optional.empty(); - // this.enabled = enabled; } public String getVariationKey() { @@ -37,8 +34,4 @@ public String getVariationKey() { public Optional getVersion() { return version; } - - // public boolean isEnabled() { - // return enabled; - // } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java index b712211..139e4d8 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java @@ -16,13 +16,14 @@ public final class Model { /** * Constructor for Model with all required fields. * - * @param name the model name + * @param name the model name * @param parameters the parameters map - * @param custom the custom map + * @param custom the custom map */ public Model(String name, Map parameters, Map custom) { this.name = name; - this.parameters = parameters != null ? Collections.unmodifiableMap(new HashMap<>(parameters)) : Collections.emptyMap(); + this.parameters = parameters != null ? Collections.unmodifiableMap(new HashMap<>(parameters)) + : Collections.emptyMap(); this.custom = custom != null ? Collections.unmodifiableMap(new HashMap<>(custom)) : Collections.emptyMap(); } From 745db03d5442d5944c8990fb7cc2013eb1a0dde5 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 19 May 2025 14:34:45 -0700 Subject: [PATCH 09/12] chore: meta should be null if key is not parsable --- .../com/launchdarkly/sdk/server/ai/LDAiClient.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 6ae89d2..a5506ad 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -75,15 +75,16 @@ protected AiConfig parseAiConfig(LDValue value, String key) { // expecting, which is good enabled = valueMeta.get("enabled").booleanValue(); - String variationKey = null; + Meta meta = null; + if (checkValueWithFailureLogging(valueMeta.get("variationKey"), LDValueType.STRING, logger, "variationKey should be a string")) { - variationKey = valueMeta.get("variationKey").stringValue(); - } - // Create Meta using constructor - Meta meta = new Meta( + String variationKey = valueMeta.get("variationKey").stringValue(); + + meta = new Meta( variationKey, Optional.of(valueMeta.get("version").intValue())); + } // Convert the optional model from an JSON object of with parameters and custom // into Model From 6eb0bc487ac21197718164e926f7d0a4533b9ce5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 22:56:14 +0000 Subject: [PATCH 10/12] test: Split single test into multiple tests for AI data model classes Co-Authored-By: lchan@launchdarkly.com --- .../sdk/server/ai/LDAiClientTest.java | 373 +++++++++++++++++- 1 file changed, 369 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java index 6520924..2c4b567 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java @@ -4,13 +4,50 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.ai.datamodel.AiConfig; +import com.launchdarkly.sdk.server.ai.datamodel.Message; import com.launchdarkly.sdk.server.ai.datamodel.Role; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +import java.util.List; +import java.util.Map; public class LDAiClientTest { + /** + * Helper method to generate valid JSON with all required fields + * This can be modified for specific test cases + */ + private String getValidBaseJson() { + return "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"1234\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [\n" + // + " {\n" + // + " \"content\": \"This is an {{ adjective }} message.\",\n" + // + " \"role\": \"user\"\n" + // + " }\n" + // + " ],\n" + // + " \"model\": {\n" + // + " \"name\": \"my-cool-custom-model\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + } + + /** + * Tests that a complete valid JSON is properly converted to an AiConfig object + */ @Test - public void testParseAiConfig() { + public void testCompleteAiConfig() { String rawJson = "{\n" + // " \"_ldMeta\": {\n" + // " \"variationKey\" : \"1234\",\n" + // @@ -47,9 +84,7 @@ public void testParseAiConfig() { "}"; LDValue input = LDValue.parse(rawJson); - LDAiClient client = new LDAiClient(null); - AiConfig result = client.parseAiConfig(input, "Whatever"); assertEquals(Role.USER, result.getMessages().get(0).getRole()); @@ -57,5 +92,335 @@ public void testParseAiConfig() { assertEquals(Integer.valueOf(1), result.getMeta().getVersion().orElse(0)); assertEquals(LDValue.of(true), result.getModel().getParameters().get("qux")); assertEquals("provider-name", result.getProvider().getName()); - }; + assertTrue(result.isEnabled()); + } + + /** + * Tests that a valid Meta object is correctly parsed + */ + @Test + public void testValidMeta() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key-123\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 42\n" + // + " },\n" + // + " \"messages\": [{\n" + // + " \"content\": \"content\",\n" + // + " \"role\": \"user\"\n" + // + " }],\n" + // + " \"model\": {\n" + // + " \"name\": \"model-name\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNotNull(result.getMeta()); + assertEquals("key-123", result.getMeta().getVariationKey()); + assertEquals(Integer.valueOf(42), result.getMeta().getVersion().orElse(0)); + assertTrue(result.isEnabled()); + } + + /** + * Tests that invalid Meta with number as variationKey is handled properly + */ + @Test + public void testInvalidMetaNumberAsVariationKey() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : 123,\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [{\n" + // + " \"content\": \"content\",\n" + // + " \"role\": \"user\"\n" + // + " }],\n" + // + " \"model\": {\n" + // + " \"name\": \"model-name\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNull(result.getMeta()); + } + + /** + * Tests that valid Messages are correctly parsed + */ + @Test + public void testValidMessages() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key-123\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [\n" + // + " {\n" + // + " \"content\": \"User message\",\n" + // + " \"role\": \"user\"\n" + // + " },\n" + // + " {\n" + // + " \"content\": \"System message\",\n" + // + " \"role\": \"system\"\n" + // + " },\n" + // + " {\n" + // + " \"content\": \"Assistant message\",\n" + // + " \"role\": \"assistant\"\n" + // + " }\n" + // + " ],\n" + // + " \"model\": {\n" + // + " \"name\": \"model-name\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNotNull(result.getMessages()); + assertEquals(3, result.getMessages().size()); + + assertEquals(Role.USER, result.getMessages().get(0).getRole()); + assertEquals("User message", result.getMessages().get(0).getContent()); + + assertEquals(Role.SYSTEM, result.getMessages().get(1).getRole()); + assertEquals("System message", result.getMessages().get(1).getContent()); + + assertEquals(Role.ASSISTANT, result.getMessages().get(2).getRole()); + assertEquals("Assistant message", result.getMessages().get(2).getContent()); + } + + /** + * Tests that invalid Messages with wrong role type are handled properly + */ + @Test + public void testInvalidMessagesInvalidRole() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [\n" + // + " {\n" + // + " \"content\": \"Invalid role\",\n" + // + " \"role\": \"invalid_role_value\"\n" + // + " }\n" + // + " ],\n" + // + " \"model\": {\n" + // + " \"name\": \"model-name\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNotNull(result.getMessages()); + assertEquals(1, result.getMessages().size()); + assertNull(result.getMessages().get(0).getRole()); + } + + /** + * Tests that a valid Model object is correctly parsed + */ + @Test + public void testValidModel() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [{\n" + // + " \"content\": \"content\",\n" + // + " \"role\": \"user\"\n" + // + " }],\n" + // + " \"model\": {\n" + // + " \"name\": \"test-model\",\n" + // + " \"parameters\": {\n" + // + " \"string_param\" : \"value\",\n" + // + " \"number_param\" : 42,\n" + // + " \"bool_param\" : true,\n" + // + " \"array_param\" : [1, 2, 3],\n" + // + " \"object_param\" : {\"key\": \"value\"}\n" + // + " },\n" + // + " \"custom\": {\n" + // + " \"custom_key\": \"custom_value\"\n" + // + " }\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNotNull(result.getModel()); + assertEquals("test-model", result.getModel().getName()); + + assertNotNull(result.getModel().getParameters()); + assertEquals(5, result.getModel().getParameters().size()); + assertEquals(LDValue.of("value"), result.getModel().getParameters().get("string_param")); + assertEquals(LDValue.of(42), result.getModel().getParameters().get("number_param")); + assertEquals(LDValue.of(true), result.getModel().getParameters().get("bool_param")); + + assertNotNull(result.getModel().getCustom()); + assertEquals(1, result.getModel().getCustom().size()); + assertEquals(LDValue.of("custom_value"), result.getModel().getCustom().get("custom_key")); + } + + /** + * Tests that invalid Model with name as non-string is handled properly + */ + @Test + public void testInvalidModelNameType() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [{\n" + // + " \"content\": \"content\",\n" + // + " \"role\": \"user\"\n" + // + " }],\n" + // + " \"model\": {\n" + // + " \"name\": 123,\n" + // + " \"parameters\": {}\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"provider-name\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNull(result.getModel()); + } + + /** + * Tests that a valid Provider object is correctly parsed + */ + @Test + public void testValidProvider() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [{\n" + // + " \"content\": \"content\",\n" + // + " \"role\": \"user\"\n" + // + " }],\n" + // + " \"model\": {\n" + // + " \"name\": \"model-name\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : \"test-provider\"\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNotNull(result.getProvider()); + assertEquals("test-provider", result.getProvider().getName()); + } + + /** + * Tests that invalid Provider with name as non-string is handled properly + */ + @Test + public void testInvalidProviderNameType() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"variationKey\" : \"key\",\n" + // + " \"enabled\": true,\n" + // + " \"version\": 1\n" + // + " },\n" + // + " \"messages\": [{\n" + // + " \"content\": \"content\",\n" + // + " \"role\": \"user\"\n" + // + " }],\n" + // + " \"model\": {\n" + // + " \"name\": \"model-name\"\n" + // + " },\n" + // + " \"provider\": {\n" + // + " \"name\" : 123\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertNotNull(result.getProvider()); + assertNull(result.getProvider().getName()); + } + + /** + * Tests that a completely invalid JSON input (not an object) is handled properly + */ + @Test + public void testInvalidJsonNotObject() { + String json = "[]"; // Array instead of object + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertFalse(result.isEnabled()); + assertNull(result.getMeta()); + assertNull(result.getModel()); + assertNull(result.getMessages()); + } + + /** + * Tests that a JSON with missing required properties is handled properly + */ + @Test + public void testMissingRequiredProperties() { + String json = "{\n" + // + " \"_ldMeta\": {\n" + // + " \"enabled\": true\n" + // + " }\n" + // + "}"; + + LDValue input = LDValue.parse(json); + LDAiClient client = new LDAiClient(null); + AiConfig result = client.parseAiConfig(input, "Whatever"); + + assertTrue(result.isEnabled()); // enabled is present and true + assertNull(result.getMeta()); // Meta should be null due to missing variationKey + assertNull(result.getModel()); + assertNull(result.getMessages()); + } } From 0c2b3b500e199037f16e2e18694eb427a0602749 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 19 May 2025 16:09:23 -0700 Subject: [PATCH 11/12] chore: touch up the tests and have provider nullable --- .../sdk/server/ai/LDAiClient.java | 18 ++++------ .../sdk/server/ai/LDAiClientTest.java | 35 ++----------------- 2 files changed, 9 insertions(+), 44 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index a5506ad..3cc590e 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -71,8 +71,7 @@ protected AiConfig parseAiConfig(LDValue value, String key) { return new AiConfig(enabled, null, null, null, null); } - // The booleanValue will get false if that value something that we are not - // expecting, which is good + // The booleanValue will get false if that value is something that we are not expecting enabled = valueMeta.get("enabled").booleanValue(); Meta meta = null; @@ -140,24 +139,25 @@ protected AiConfig parseAiConfig(LDValue value, String key) { // Convert the optional provider from an JSON object of with name into Provider LDValue valueProvider = value.get("provider"); - String providerName = null; + Provider provider = null; if (checkValueWithFailureLogging(valueProvider, LDValueType.OBJECT, logger, "non-null provider must be a JSON object")) { if (checkValueWithFailureLogging(valueProvider.get("name"), LDValueType.STRING, logger, "provider name must be a String")) { - providerName = valueProvider.get("name").stringValue(); + String providerName = valueProvider.get("name").stringValue(); + + provider = new Provider(providerName); } } - Provider provider = new Provider(providerName); - return new AiConfig(enabled, meta, model, messages, provider); } protected boolean checkValueWithFailureLogging(LDValue ldValue, LDValueType expectedType, LDLogger logger, String message) { if (ldValue.getType() != expectedType) { + // TODO: In the next PR, make this required with some sort of default logger if (logger != null) { logger.error(message); } @@ -165,10 +165,4 @@ protected boolean checkValueWithFailureLogging(LDValue ldValue, LDValueType expe } return true; } - - class AiConfigParseException extends Exception { - AiConfigParseException(String exceptionMessage) { - super(exceptionMessage); - } - } } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java index 2c4b567..05a1f65 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAiClientTest.java @@ -4,7 +4,6 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.ai.datamodel.AiConfig; -import com.launchdarkly.sdk.server.ai.datamodel.Message; import com.launchdarkly.sdk.server.ai.datamodel.Role; import static org.junit.Assert.assertEquals; @@ -13,36 +12,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; -import java.util.List; -import java.util.Map; - public class LDAiClientTest { - /** - * Helper method to generate valid JSON with all required fields - * This can be modified for specific test cases - */ - private String getValidBaseJson() { - return "{\n" + // - " \"_ldMeta\": {\n" + // - " \"variationKey\" : \"1234\",\n" + // - " \"enabled\": true,\n" + // - " \"version\": 1\n" + // - " },\n" + // - " \"messages\": [\n" + // - " {\n" + // - " \"content\": \"This is an {{ adjective }} message.\",\n" + // - " \"role\": \"user\"\n" + // - " }\n" + // - " ],\n" + // - " \"model\": {\n" + // - " \"name\": \"my-cool-custom-model\"\n" + // - " },\n" + // - " \"provider\": {\n" + // - " \"name\" : \"provider-name\"\n" + // - " }\n" + // - "}"; - } - /** * Tests that a complete valid JSON is properly converted to an AiConfig object */ @@ -382,8 +352,7 @@ public void testInvalidProviderNameType() { LDAiClient client = new LDAiClient(null); AiConfig result = client.parseAiConfig(input, "Whatever"); - assertNotNull(result.getProvider()); - assertNull(result.getProvider().getName()); + assertNull(result.getProvider()); } /** @@ -401,6 +370,7 @@ public void testInvalidJsonNotObject() { assertNull(result.getMeta()); assertNull(result.getModel()); assertNull(result.getMessages()); + assertNull(result.getProvider()); } /** @@ -422,5 +392,6 @@ public void testMissingRequiredProperties() { assertNull(result.getMeta()); // Meta should be null due to missing variationKey assertNull(result.getModel()); assertNull(result.getMessages()); + assertNull(result.getProvider()); } } From 5244939b8ab26f4ec7e20e0e3c349ad3ac939e59 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:37:19 +0000 Subject: [PATCH 12/12] chore: implement builder pattern for datamodel classes and make constructors package-private Co-Authored-By: lchan@launchdarkly.com --- .../sdk/server/ai/LDAiClient.java | 27 +++++++---- .../sdk/server/ai/datamodel/AiConfig.java | 45 ++++++++++++++++++- .../sdk/server/ai/datamodel/Message.java | 36 +++++++++++++-- .../sdk/server/ai/datamodel/Meta.java | 32 ++++++++++++- .../sdk/server/ai/datamodel/Model.java | 33 +++++++++++++- .../sdk/server/ai/datamodel/Provider.java | 23 +++++++++- 6 files changed, 179 insertions(+), 17 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java index 3cc590e..e3bda4a 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAiClient.java @@ -60,7 +60,7 @@ protected AiConfig parseAiConfig(LDValue value, String key) { // Verify the whole value is a JSON object if (!checkValueWithFailureLogging(value, LDValueType.OBJECT, logger, "Input to parseAiConfig must be a JSON object")) { - return new AiConfig(enabled, null, null, null, null); + return AiConfig.builder().enabled(enabled).build(); } // Convert the _meta JSON object into Meta @@ -68,7 +68,7 @@ protected AiConfig parseAiConfig(LDValue value, String key) { if (!checkValueWithFailureLogging(valueMeta, LDValueType.OBJECT, logger, "_ldMeta must be a JSON object")) { // Q: If we can't read _meta, enabled by spec would be defaulted to false. Does // it even matter the rest of the values? - return new AiConfig(enabled, null, null, null, null); + return AiConfig.builder().enabled(enabled).build(); } // The booleanValue will get false if that value is something that we are not expecting @@ -80,9 +80,10 @@ protected AiConfig parseAiConfig(LDValue value, String key) { "variationKey should be a string")) { String variationKey = valueMeta.get("variationKey").stringValue(); - meta = new Meta( - variationKey, - Optional.of(valueMeta.get("version").intValue())); + meta = Meta.builder() + .variationKey(variationKey) + .version(Optional.of(valueMeta.get("version").intValue())) + .build(); } // Convert the optional model from an JSON object of with parameters and custom @@ -119,7 +120,11 @@ protected AiConfig parseAiConfig(LDValue value, String key) { } } - model = new Model(modelName, parameters, custom); + model = Model.builder() + .name(modelName) + .parameters(parameters) + .custom(custom) + .build(); } } @@ -147,11 +152,17 @@ protected AiConfig parseAiConfig(LDValue value, String key) { "provider name must be a String")) { String providerName = valueProvider.get("name").stringValue(); - provider = new Provider(providerName); + provider = Provider.builder().name(providerName).build(); } } - return new AiConfig(enabled, meta, model, messages, provider); + return AiConfig.builder() + .enabled(enabled) + .meta(meta) + .model(model) + .messages(messages) + .provider(provider) + .build(); } protected boolean checkValueWithFailureLogging(LDValue ldValue, LDValueType expectedType, LDLogger logger, diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java index a85026e..8fdcb78 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AiConfig.java @@ -13,7 +13,7 @@ public final class AiConfig { private final Provider provider; - public AiConfig(boolean enabled, Meta meta, Model model, List messages, Provider provider) { + AiConfig(boolean enabled, Meta meta, Model model, List messages, Provider provider) { this.enabled = enabled; this.meta = meta; this.model = model; @@ -40,4 +40,47 @@ public Model getModel() { public Provider getProvider() { return provider; } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private boolean enabled; + private Meta meta; + private Model model; + private List messages; + private Provider provider; + + private Builder() {} + + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder meta(Meta meta) { + this.meta = meta; + return this; + } + + public Builder model(Model model) { + this.model = model; + return this; + } + + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + public Builder provider(Provider provider) { + this.provider = provider; + return this; + } + + public AiConfig build() { + return new AiConfig(enabled, meta, model, messages, provider); + } + } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java index eb0aee9..8ac89ec 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Message.java @@ -14,15 +14,18 @@ public LDValue fromType(Message message) { @Override public Message toType(LDValue ldValue) { - return new Message(ldValue.get("content").stringValue(), Role.getRole(ldValue.get("role").stringValue())); + return Message.builder() + .content(ldValue.get("content").stringValue()) + .role(Role.getRole(ldValue.get("role").stringValue())) + .build(); } } - private String content; + private final String content; - private Role role; + private final Role role; - public Message(String content, Role role) { + Message(String content, Role role) { this.content = content; this.role = role; } @@ -34,4 +37,29 @@ public String getContent() { public Role getRole() { return role; } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String content; + private Role role; + + private Builder() {} + + public Builder content(String content) { + this.content = content; + return this; + } + + public Builder role(Role role) { + this.role = role; + return this; + } + + public Message build() { + return new Message(content, role); + } + } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java index b50468b..55af783 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Meta.java @@ -22,7 +22,7 @@ public final class Meta { * @param variationKey the variation key * @param version the version */ - public Meta(String variationKey, Optional version) { + Meta(String variationKey, Optional version) { this.variationKey = variationKey; this.version = version != null ? version : Optional.empty(); } @@ -34,4 +34,34 @@ public String getVariationKey() { public Optional getVersion() { return version; } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String variationKey; + private Optional version = Optional.empty(); + + private Builder() {} + + public Builder variationKey(String variationKey) { + this.variationKey = variationKey; + return this; + } + + public Builder version(Optional version) { + this.version = version; + return this; + } + + public Builder version(int version) { + this.version = Optional.of(version); + return this; + } + + public Meta build() { + return new Meta(variationKey, version); + } + } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java index 139e4d8..ee2f7f5 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Model.java @@ -20,7 +20,7 @@ public final class Model { * @param parameters the parameters map * @param custom the custom map */ - public Model(String name, Map parameters, Map custom) { + Model(String name, Map parameters, Map custom) { this.name = name; this.parameters = parameters != null ? Collections.unmodifiableMap(new HashMap<>(parameters)) : Collections.emptyMap(); @@ -38,4 +38,35 @@ public Map getParameters() { public Map getCustom() { return custom; } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String name; + private Map parameters; + private Map custom; + + private Builder() {} + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder parameters(Map parameters) { + this.parameters = parameters; + return this; + } + + public Builder custom(Map custom) { + this.custom = custom; + return this; + } + + public Model build() { + return new Model(name, parameters, custom); + } + } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java index 6b35fa1..614408d 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Provider.java @@ -1,13 +1,32 @@ package com.launchdarkly.sdk.server.ai.datamodel; public final class Provider { - private String name; + private final String name; - public Provider(String name) { + Provider(String name) { this.name = name; } public String getName() { return name; } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String name; + + private Builder() {} + + public Builder name(String name) { + this.name = name; + return this; + } + + public Provider build() { + return new Provider(name); + } + } }