From 7ab69a2d20598af8286281790fc6b82667035aaf Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 2 Jun 2026 10:36:29 -0400 Subject: [PATCH] Scaffold Java AI SDK --- .github/workflows/java-server-sdk-ai.yml | 23 + .release-please-manifest.json | 1 + lib/java-server-sdk-ai/.gitignore | 6 + lib/java-server-sdk-ai/README.md | 133 +++++ lib/java-server-sdk-ai/build.gradle | 112 +++++ lib/java-server-sdk-ai/gradle.properties | 8 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61608 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + lib/java-server-sdk-ai/gradlew | 244 +++++++++ lib/java-server-sdk-ai/gradlew.bat | 92 ++++ lib/java-server-sdk-ai/settings.gradle | 1 + .../sdk/server/ai/LDAIClient.java | 476 ++++++++++++++++++ .../server/ai/datamodel/AIAgentConfig.java | 151 ++++++ .../ai/datamodel/AIAgentConfigDefault.java | 143 ++++++ .../ai/datamodel/AIAgentConfigRequest.java | 66 +++ .../ai/datamodel/AIAgentGraphConfig.java | 67 +++ .../ai/datamodel/AICompletionConfig.java | 154 ++++++ .../datamodel/AICompletionConfigDefault.java | 162 ++++++ .../sdk/server/ai/datamodel/AIConfig.java | 90 ++++ .../server/ai/datamodel/AIConfigDefault.java | 77 +++ .../server/ai/datamodel/AIJudgeConfig.java | 115 +++++ .../ai/datamodel/AIJudgeConfigDefault.java | 118 +++++ .../sdk/server/ai/datamodel/Edge.java | 92 ++++ .../ai/datamodel/JudgeConfiguration.java | 142 ++++++ .../sdk/server/ai/datamodel/LDMessage.java | 118 +++++ .../sdk/server/ai/datamodel/LDTool.java | 201 ++++++++ .../sdk/server/ai/datamodel/ModelConfig.java | 198 ++++++++ .../server/ai/datamodel/ProviderConfig.java | 61 +++ .../sdk/server/ai/evaluation/Evaluator.java | 63 +++ .../sdk/server/ai/evaluation/Judge.java | 33 ++ .../sdk/server/ai/evaluation/JudgeResult.java | 175 +++++++ .../sdk/server/ai/internal/AISdkInfo.java | 24 + .../sdk/server/ai/internal/Interpolator.java | 55 ++ .../ai/internal/LDValueConversions.java | 66 +++ .../server/ai/internal/ResumptionTokens.java | 180 +++++++ .../server/ai/tracking/AIConfigTracker.java | 442 ++++++++++++++++ .../sdk/server/ai/tracking/AIMetrics.java | 113 +++++ .../sdk/server/ai/tracking/FeedbackKind.java | 26 + .../sdk/server/ai/tracking/MetricSummary.java | 117 +++++ .../sdk/server/ai/tracking/TokenUsage.java | 74 +++ .../sdk/server/ai/LDAIClientTest.java | 377 ++++++++++++++ .../sdk/server/ai/MockLDClient.java | 204 ++++++++ .../server/ai/evaluation/EvaluatorTest.java | 62 +++ .../server/ai/internal/InterpolatorTest.java | 56 +++ .../ai/internal/ResumptionTokensTest.java | 77 +++ .../ai/tracking/AIConfigTrackerTest.java | 229 +++++++++ release-please-config.json | 9 + 47 files changed, 5439 insertions(+) create mode 100644 .github/workflows/java-server-sdk-ai.yml create mode 100644 lib/java-server-sdk-ai/.gitignore create mode 100644 lib/java-server-sdk-ai/README.md create mode 100644 lib/java-server-sdk-ai/build.gradle create mode 100644 lib/java-server-sdk-ai/gradle.properties create mode 100644 lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.jar create mode 100644 lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.properties create mode 100755 lib/java-server-sdk-ai/gradlew create mode 100644 lib/java-server-sdk-ai/gradlew.bat create mode 100644 lib/java-server-sdk-ai/settings.gradle create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigDefault.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigRequest.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentGraphConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfigDefault.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigDefault.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfigDefault.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Edge.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDTool.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Evaluator.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Judge.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/JudgeResult.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConversions.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokens.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTracker.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIMetrics.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/FeedbackKind.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/MetricSummary.java create mode 100644 lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/TokenUsage.java create mode 100644 lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientTest.java create mode 100644 lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/MockLDClient.java create mode 100644 lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/evaluation/EvaluatorTest.java create mode 100644 lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java create mode 100644 lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokensTest.java create mode 100644 lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTrackerTest.java diff --git a/.github/workflows/java-server-sdk-ai.yml b/.github/workflows/java-server-sdk-ai.yml new file mode 100644 index 00000000..2a6336ce --- /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/java-server-sdk-ai' + java_version: 8 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d1a998f4..bb581775 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,5 @@ { + "lib/java-server-sdk-ai": "0.1.0", "lib/java-server-sdk-otel": "0.2.0", "lib/java-server-sdk-redis-store": "3.1.1", "lib/shared/common": "2.4.0", diff --git a/lib/java-server-sdk-ai/.gitignore b/lib/java-server-sdk-ai/.gitignore new file mode 100644 index 00000000..d67a5bc6 --- /dev/null +++ b/lib/java-server-sdk-ai/.gitignore @@ -0,0 +1,6 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build +bin diff --git a/lib/java-server-sdk-ai/README.md b/lib/java-server-sdk-ai/README.md new file mode 100644 index 00000000..6b74d751 --- /dev/null +++ b/lib/java-server-sdk-ai/README.md @@ -0,0 +1,133 @@ +# LaunchDarkly Server-side AI SDK for Java + +This package provides AI Config functionality for the LaunchDarkly Java Server SDK. It lets you manage prompts, models, and providers as LaunchDarkly AI Configs, interpolate variables into messages and instructions, and record AI metrics (duration, token usage, generation success, feedback, tool calls, and online judge evaluations) back to LaunchDarkly. + +It is built on top of the [LaunchDarkly Java Server SDK](https://github.com/launchdarkly/java-server-sdk) and is feature-compatible with the [LaunchDarkly Server-side AI SDK for Python](https://github.com/launchdarkly/python-server-sdk-ai). + +## LaunchDarkly overview + +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +## Supported Java versions + +This package is compatible with Java 8 and above, matching the LaunchDarkly Java Server SDK. + +## Getting started + +1. Add the `launchdarkly-java-server-sdk-ai` package to your project, alongside the core Java Server SDK: + +``` +dependencies { + implementation "com.launchdarkly:launchdarkly-java-server-sdk:7+" + implementation "com.launchdarkly:launchdarkly-java-server-sdk-ai:0.1.0" +} +``` + +2. Construct an `LDAIClient`, wrapping your existing `LDClient`: + +```java +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.server.*; +import com.launchdarkly.sdk.server.ai.*; +import com.launchdarkly.sdk.server.ai.datamodel.*; + +LDClient ldClient = new LDClient("sdk-key-123abc"); +LDAIClient aiClient = new LDAIClient(ldClient); +``` + +A single `LDAIClient` should be reused for the lifetime of your application. + +## Completion configs + +A completion config carries chat-style messages, a model, and a provider. Provide a default to use when the AI Config is unavailable, and any variables to interpolate into the messages. + +```java +import java.util.Collections; +import java.util.Arrays; + +LDContext context = LDContext.create("user-key-123abc"); + +AICompletionConfig config = aiClient.completionConfig( + "my-ai-config", + context, + AICompletionConfigDefault.builder() + .enabled(true) + .model(new ModelConfig("gpt-4")) + .messages(Arrays.asList( + LDMessage.system("You are a helpful assistant named {{name}}."))) + .build(), + Collections.singletonMap("name", "Bailey")); + +if (config.isEnabled()) { + AIConfigTracker tracker = config.createTracker(); + String answer = tracker.trackDurationOf(() -> callYourModel(config)); + tracker.trackSuccess(); +} +``` + +Message and instruction content is interpolated with Mustache templating. In addition to any +variables you supply, an `ldctx` variable is always available, exposing the attributes of the +evaluation context (for example `{{ldctx.name}}`, or `{{ldctx.user.name}}` for a multi-context). + +## Agent configs + +An agent config carries freeform `instructions` rather than chat messages. Retrieve a single agent +with `agentConfig`, or several at once with `agentConfigs`: + +```java +AIAgentConfig agent = aiClient.agentConfig( + "research_agent", + context, + AIAgentConfigDefault.builder() + .enabled(true) + .model(new ModelConfig("gpt-4")) + .instructions("You are a research assistant specializing in {{topic}}.") + .build(), + Collections.singletonMap("topic", "climate change")); +``` + +## Tracking metrics + +The `AIConfigTracker` returned by `createTracker()` records metrics for a single AI run. Each scalar +metric (duration, success/error, feedback, tokens, time-to-first-token) is recorded at most once per +tracker; obtain a new tracker for each run. + +```java +AIConfigTracker tracker = config.createTracker(); + +tracker.trackDuration(durationMs); +tracker.trackTokens(new TokenUsage(/* total */ 30, /* input */ 10, /* output */ 20)); +tracker.trackSuccess(); // or tracker.trackError(); +tracker.trackFeedback(FeedbackKind.POSITIVE); +``` + +To associate deferred events (such as user feedback gathered later) with the original run, persist +the tracker's resumption token and reconstruct the tracker from it: + +```java +String token = tracker.getResumptionToken(); +// ...later, possibly in a different process... +AIConfigTracker resumed = aiClient.createTracker(token, context); +resumed.trackFeedback(FeedbackKind.POSITIVE); +``` + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## About LaunchDarkly + +- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + - Grant access to certain features based on user attributes, like payment plan (eg: users on the 'gold' plan get access to more features than users in the 'silver' plan). + - Disable parts of your application to facilitate maintenance, without taking everything offline. +- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +- Explore LaunchDarkly + - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information + - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides + - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation + - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates diff --git a/lib/java-server-sdk-ai/build.gradle b/lib/java-server-sdk-ai/build.gradle new file mode 100644 index 00000000..4b725692 --- /dev/null +++ b/lib/java-server-sdk-ai/build.gradle @@ -0,0 +1,112 @@ +plugins { + // Apply the java-library plugin for API and implementation separation. + id 'java-library' + id 'maven-publish' + id 'signing' + id 'jacoco' + id 'io.github.gradle-nexus.publish-plugin' version '1.3.0' +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() + maven { url 'https://oss.sonatype.org/content/groups/public/' } +} + +allprojects { + group = 'com.launchdarkly' + version = "${version}" + archivesBaseName = 'launchdarkly-java-server-sdk-ai' + sourceCompatibility = 1.8 + targetCompatibility = 1.8 +} + +ext.versions = [ + "launchdarklyJavaServerSdk": "7.6.0", + "jmustache" : "1.16", +] + +dependencies { + // The AI SDK is a thin layer on top of a fully-configured Java server SDK client. + api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.launchdarklyJavaServerSdk}" + + // Logic-less Mustache templating for prompt/instruction interpolation. jmustache is a + // single, dependency-free jar, which keeps the AI SDK's transitive footprint small. + implementation "com.samskivert:jmustache:${versions.jmustache}" + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.hamcrest:hamcrest:2.2' +} + +test { + useJUnit() + testLogging { + events 'passed', 'skipped', 'failed' + exceptionFormat 'full' + showStandardStreams = false + } +} + +java { + withJavadocJar() + withSourcesJar() +} + +javadoc { + // Surface only the public API in generated documentation. + options.addStringOption('Xdoclint:none', '-quiet') +} + +jacocoTestReport { + reports { + xml.required = true + html.required = true + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + + pom { + name.set('LaunchDarkly Java Server-Side AI SDK') + description.set('LaunchDarkly Server-Side AI SDK for Java, providing AI Config management, tracking, and online evaluation built on top of the LaunchDarkly Java Server SDK.') + url.set('https://github.com/launchdarkly/java-core') + licenses { + license { + name.set('The Apache License, Version 2.0') + url.set('http://www.apache.org/licenses/LICENSE-2.0.txt') + } + } + developers { + developer { + name.set('LaunchDarkly SDK Team') + email.set('sdks@launchdarkly.com') + } + } + scm { + connection.set('scm:git:git://github.com/launchdarkly/java-core.git') + developerConnection.set('scm:git:ssh:git@github.com:launchdarkly/java-core.git') + url.set('https://github.com/launchdarkly/java-core') + } + } + } + } +} + +nexusPublishing { + clientTimeout = java.time.Duration.ofMinutes(2) + repositories { + sonatype { + nexusUrl.set(uri('https://ossrh-staging-api.central.sonatype.com/service/local/')) + snapshotRepositoryUrl.set(uri('https://central.sonatype.com/repository/maven-snapshots/')) + } + } +} + +signing { + // Only sign when explicitly publishing with credentials present. + required { gradle.taskGraph.hasTask('publishToSonatype') } + sign(publishing.publications['mavenJava']) +} diff --git a/lib/java-server-sdk-ai/gradle.properties b/lib/java-server-sdk-ai/gradle.properties new file mode 100644 index 00000000..21bcaf39 --- /dev/null +++ b/lib/java-server-sdk-ai/gradle.properties @@ -0,0 +1,8 @@ +#x-release-please-start-version +version=0.1.0 +#x-release-please-end + +# The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework +# and should not be needed for typical development purposes (including by third-party developers). +sonatypeUsername= +sonatypePassword= diff --git a/lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.jar b/lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..ccebba7710deaf9f98673a68957ea02138b60d0a GIT binary patch literal 61608 zcmb5VV{~QRw)Y#`wrv{~+qP{x72B%VwzFc}c2cp;N~)5ZbDrJayPv(!dGEd-##*zr z)#n-$y^sH|_dchh3@8{H5D*j;5D<{i*8l5IFJ|DjL!e)upfGNX(kojugZ3I`oH1PvW`wFW_ske0j@lB9bX zO;2)`y+|!@X(fZ1<2n!Qx*)_^Ai@Cv-dF&(vnudG?0CsddG_&Wtae(n|K59ew)6St z#dj7_(Cfwzh$H$5M!$UDd8=4>IQsD3xV=lXUq($;(h*$0^yd+b{qq63f0r_de#!o_ zXDngc>zy`uor)4A^2M#U*DC~i+dc<)Tb1Tv&~Ev@oM)5iJ4Sn#8iRw16XXuV50BS7 zdBL5Mefch(&^{luE{*5qtCZk$oFr3RH=H!c3wGR=HJ(yKc_re_X9pD` zJ;uxPzUfVpgU>DSq?J;I@a+10l0ONXPcDkiYcihREt5~T5Gb}sT0+6Q;AWHl`S5dV>lv%-p9l#xNNy7ZCr%cyqHY%TZ8Q4 zbp&#ov1*$#grNG#1vgfFOLJCaNG@K|2!W&HSh@3@Y%T?3YI75bJp!VP*$*!< z;(ffNS_;@RJ`=c7yX04!u3JP*<8jeqLHVJu#WV&v6wA!OYJS4h<_}^QI&97-;=ojW zQ-1t)7wnxG*5I%U4)9$wlv5Fr;cIizft@&N+32O%B{R1POm$oap@&f| zh+5J{>U6ftv|vAeKGc|zC=kO(+l7_cLpV}-D#oUltScw})N>~JOZLU_0{Ka2e1evz z{^a*ZrLr+JUj;)K&u2CoCAXLC2=fVScI(m_p~0FmF>>&3DHziouln?;sxW`NB}cSX z8?IsJB)Z=aYRz!X=yJn$kyOWK%rCYf-YarNqKzmWu$ZvkP12b4qH zhS9Q>j<}(*frr?z<%9hl*i^#@*O2q(Z^CN)c2c z>1B~D;@YpG?G!Yk+*yn4vM4sO-_!&m6+`k|3zd;8DJnxsBYtI;W3We+FN@|tQ5EW= z!VU>jtim0Mw#iaT8t_<+qKIEB-WwE04lBd%Letbml9N!?SLrEG$nmn7&W(W`VB@5S zaY=sEw2}i@F_1P4OtEw?xj4@D6>_e=m=797#hg}f*l^`AB|Y0# z9=)o|%TZFCY$SzgSjS|8AI-%J4x}J)!IMxY3_KYze`_I=c1nmrk@E8c9?MVRu)7+Ue79|)rBX7tVB7U|w4*h(;Gi3D9le49B38`wuv zp7{4X^p+K4*$@gU(Tq3K1a#3SmYhvI42)GzG4f|u zwQFT1n_=n|jpi=70-yE9LA+d*T8u z`=VmmXJ_f6WmZveZPct$Cgu^~gFiyL>Lnpj*6ee>*0pz=t$IJ}+rE zsf@>jlcG%Wx;Cp5x)YSVvB1$yyY1l&o zvwX=D7k)Dn;ciX?Z)Pn8$flC8#m`nB&(8?RSdBvr?>T9?E$U3uIX7T?$v4dWCa46 z+&`ot8ZTEgp7G+c52oHJ8nw5}a^dwb_l%MOh(ebVj9>_koQP^$2B~eUfSbw9RY$_< z&DDWf2LW;b0ZDOaZ&2^i^g+5uTd;GwO(-bbo|P^;CNL-%?9mRmxEw~5&z=X^Rvbo^WJW=n_%*7974RY}JhFv46> zd}`2|qkd;89l}R;i~9T)V-Q%K)O=yfVKNM4Gbacc7AOd>#^&W&)Xx!Uy5!BHnp9kh z`a(7MO6+Ren#>R^D0K)1sE{Bv>}s6Rb9MT14u!(NpZOe-?4V=>qZ>}uS)!y~;jEUK z&!U7Fj&{WdgU#L0%bM}SYXRtM5z!6M+kgaMKt%3FkjWYh=#QUpt$XX1!*XkpSq-pl zhMe{muh#knk{9_V3%qdDcWDv}v)m4t9 zQhv{;} zc{}#V^N3H>9mFM8`i`0p+fN@GqX+kl|M94$BK3J-X`Hyj8r!#x6Vt(PXjn?N)qedP z=o1T^#?1^a{;bZ&x`U{f?}TMo8ToN zkHj5v|}r}wDEi7I@)Gj+S1aE-GdnLN+$hw!=DzglMaj#{qjXi_dwpr|HL(gcCXwGLEmi|{4&4#OZ4ChceA zKVd4K!D>_N=_X;{poT~4Q+!Le+ZV>=H7v1*l%w`|`Dx8{)McN@NDlQyln&N3@bFpV z_1w~O4EH3fF@IzJ9kDk@7@QctFq8FbkbaH7K$iX=bV~o#gfh?2JD6lZf(XP>~DACF)fGFt)X%-h1yY~MJU{nA5 ze2zxWMs{YdX3q5XU*9hOH0!_S24DOBA5usB+Ws$6{|AMe*joJ?RxfV}*7AKN9V*~J zK+OMcE@bTD>TG1*yc?*qGqjBN8mgg@h1cJLDv)0!WRPIkC` zZrWXrceVw;fB%3`6kq=a!pq|hFIsQ%ZSlo~)D z|64!aCnw-?>}AG|*iOl44KVf8@|joXi&|)1rB;EQWgm+iHfVbgllP$f!$Wf42%NO5b(j9Bw6L z;0dpUUK$5GX4QbMlTmLM_jJt!ur`_0~$b#BB7FL*%XFf<b__1o)Ao3rlobbN8-(T!1d-bR8D3S0@d zLI!*GMb5s~Q<&sjd}lBb8Nr0>PqE6_!3!2d(KAWFxa{hm`@u|a(%#i(#f8{BP2wbs zt+N_slWF4IF_O|{w`c~)Xvh&R{Au~CFmW#0+}MBd2~X}t9lz6*E7uAD`@EBDe$>7W zzPUkJx<`f$0VA$=>R57^(K^h86>09?>_@M(R4q($!Ck6GG@pnu-x*exAx1jOv|>KH zjNfG5pwm`E-=ydcb+3BJwuU;V&OS=6yM^4Jq{%AVqnTTLwV`AorIDD}T&jWr8pB&j28fVtk_y*JRP^t@l*($UZ z6(B^-PBNZ+z!p?+e8@$&jCv^EWLb$WO=}Scr$6SM*&~B95El~;W_0(Bvoha|uQ1T< zO$%_oLAwf1bW*rKWmlD+@CP&$ObiDy=nh1b2ejz%LO9937N{LDe7gle4i!{}I$;&Y zkexJ9Ybr+lrCmKWg&}p=`2&Gf10orS?4$VrzWidT=*6{KzOGMo?KI0>GL0{iFWc;C z+LPq%VH5g}6V@-tg2m{C!-$fapJ9y}c$U}aUmS{9#0CM*8pC|sfer!)nG7Ji>mfRh z+~6CxNb>6eWKMHBz-w2{mLLwdA7dA-qfTu^A2yG1+9s5k zcF=le_UPYG&q!t5Zd_*E_P3Cf5T6821bO`daa`;DODm8Ih8k89=RN;-asHIigj`n=ux>*f!OC5#;X5i;Q z+V!GUy0|&Y_*8k_QRUA8$lHP;GJ3UUD08P|ALknng|YY13)}!!HW@0z$q+kCH%xet zlWf@BXQ=b=4}QO5eNnN~CzWBbHGUivG=`&eWK}beuV*;?zt=P#pM*eTuy3 zP}c#}AXJ0OIaqXji78l;YrP4sQe#^pOqwZUiiN6^0RCd#D271XCbEKpk`HI0IsN^s zES7YtU#7=8gTn#lkrc~6)R9u&SX6*Jk4GFX7){E)WE?pT8a-%6P+zS6o&A#ml{$WX zABFz#i7`DDlo{34)oo?bOa4Z_lNH>n;f0nbt$JfAl~;4QY@}NH!X|A$KgMmEsd^&Y zt;pi=>AID7ROQfr;MsMtClr5b0)xo|fwhc=qk33wQ|}$@?{}qXcmECh>#kUQ-If0$ zseb{Wf4VFGLNc*Rax#P8ko*=`MwaR-DQ8L8V8r=2N{Gaips2_^cS|oC$+yScRo*uF zUO|5=?Q?{p$inDpx*t#Xyo6=s?bbN}y>NNVxj9NZCdtwRI70jxvm3!5R7yiWjREEd zDUjrsZhS|P&|Ng5r+f^kA6BNN#|Se}_GF>P6sy^e8kBrgMv3#vk%m}9PCwUWJg-AD zFnZ=}lbi*mN-AOm zCs)r=*YQAA!`e#1N>aHF=bb*z*hXH#Wl$z^o}x##ZrUc=kh%OHWhp=7;?8%Xj||@V?1c ziWoaC$^&04;A|T)!Zd9sUzE&$ODyJaBpvqsw19Uiuq{i#VK1!htkdRWBnb z`{rat=nHArT%^R>u#CjjCkw-7%g53|&7z-;X+ewb?OLWiV|#nuc8mp*LuGSi3IP<<*Wyo9GKV7l0Noa4Jr0g3p_$ z*R9{qn=?IXC#WU>48-k5V2Oc_>P;4_)J@bo1|pf=%Rcbgk=5m)CJZ`caHBTm3%!Z9 z_?7LHr_BXbKKr=JD!%?KhwdYSdu8XxPoA{n8^%_lh5cjRHuCY9Zlpz8g+$f@bw@0V z+6DRMT9c|>1^3D|$Vzc(C?M~iZurGH2pXPT%F!JSaAMdO%!5o0uc&iqHx?ImcX6fI zCApkzc~OOnfzAd_+-DcMp&AOQxE_EsMqKM{%dRMI5`5CT&%mQO?-@F6tE*xL?aEGZ z8^wH@wRl`Izx4sDmU>}Ym{ybUm@F83qqZPD6nFm?t?(7>h*?`fw)L3t*l%*iw0Qu#?$5eq!Qc zpQvqgSxrd83NsdO@lL6#{%lsYXWen~d3p4fGBb7&5xqNYJ)yn84!e1PmPo7ChVd%4 zHUsV0Mh?VpzZD=A6%)Qrd~i7 z96*RPbid;BN{Wh?adeD_p8YU``kOrGkNox3D9~!K?w>#kFz!4lzOWR}puS(DmfjJD z`x0z|qB33*^0mZdM&6$|+T>fq>M%yoy(BEjuh9L0>{P&XJ3enGpoQRx`v6$txXt#c z0#N?b5%srj(4xmPvJxrlF3H%OMB!jvfy z;wx8RzU~lb?h_}@V=bh6p8PSb-dG|-T#A?`c&H2`_!u+uenIZe`6f~A7r)`9m8atC zt(b|6Eg#!Q*DfRU=Ix`#B_dK)nnJ_+>Q<1d7W)eynaVn`FNuN~%B;uO2}vXr5^zi2 z!ifIF5@Zlo0^h~8+ixFBGqtweFc`C~JkSq}&*a3C}L?b5Mh-bW=e)({F_g4O3 zb@SFTK3VD9QuFgFnK4Ve_pXc3{S$=+Z;;4+;*{H}Rc;845rP?DLK6G5Y-xdUKkA6E3Dz&5f{F^FjJQ(NSpZ8q-_!L3LL@H* zxbDF{gd^U3uD;)a)sJwAVi}7@%pRM&?5IaUH%+m{E)DlA_$IA1=&jr{KrhD5q&lTC zAa3c)A(K!{#nOvenH6XrR-y>*4M#DpTTOGQEO5Jr6kni9pDW`rvY*fs|ItV;CVITh z=`rxcH2nEJpkQ^(;1c^hfb8vGN;{{oR=qNyKtR1;J>CByul*+=`NydWnSWJR#I2lN zTvgnR|MBx*XFsfdA&;tr^dYaqRZp*2NwkAZE6kV@1f{76e56eUmGrZ>MDId)oqSWw z7d&r3qfazg+W2?bT}F)4jD6sWaw`_fXZGY&wnGm$FRPFL$HzVTH^MYBHWGCOk-89y zA+n+Q6EVSSCpgC~%uHfvyg@ufE^#u?JH?<73A}jj5iILz4Qqk5$+^U(SX(-qv5agK znUkfpke(KDn~dU0>gdKqjTkVk`0`9^0n_wzXO7R!0Thd@S;U`y)VVP&mOd-2 z(hT(|$=>4FY;CBY9#_lB$;|Wd$aOMT5O_3}DYXEHn&Jrc3`2JiB`b6X@EUOD zVl0S{ijm65@n^19T3l%>*;F(?3r3s?zY{thc4%AD30CeL_4{8x6&cN}zN3fE+x<9; zt2j1RRVy5j22-8U8a6$pyT+<`f+x2l$fd_{qEp_bfxfzu>ORJsXaJn4>U6oNJ#|~p z`*ZC&NPXl&=vq2{Ne79AkQncuxvbOG+28*2wU$R=GOmns3W@HE%^r)Fu%Utj=r9t` zd;SVOnA(=MXgnOzI2@3SGKHz8HN~Vpx&!Ea+Df~`*n@8O=0!b4m?7cE^K*~@fqv9q zF*uk#1@6Re_<^9eElgJD!nTA@K9C732tV~;B`hzZ321Ph=^BH?zXddiu{Du5*IPg} zqDM=QxjT!Rp|#Bkp$(mL)aar)f(dOAXUiw81pX0DC|Y4;>Vz>>DMshoips^8Frdv} zlTD=cKa48M>dR<>(YlLPOW%rokJZNF2gp8fwc8b2sN+i6&-pHr?$rj|uFgktK@jg~ zIFS(%=r|QJ=$kvm_~@n=ai1lA{7Z}i+zj&yzY+!t$iGUy|9jH#&oTNJ;JW-3n>DF+ z3aCOzqn|$X-Olu_p7brzn`uk1F*N4@=b=m;S_C?#hy{&NE#3HkATrg?enaVGT^$qIjvgc61y!T$9<1B@?_ibtDZ{G zeXInVr5?OD_nS_O|CK3|RzzMmu+8!#Zb8Ik;rkIAR%6?$pN@d<0dKD2c@k2quB%s( zQL^<_EM6ow8F6^wJN1QcPOm|ehA+dP(!>IX=Euz5qqIq}Y3;ibQtJnkDmZ8c8=Cf3 zu`mJ!Q6wI7EblC5RvP*@)j?}W=WxwCvF3*5Up_`3*a~z$`wHwCy)2risye=1mSp%p zu+tD6NAK3o@)4VBsM!@);qgsjgB$kkCZhaimHg&+k69~drbvRTacWKH;YCK(!rC?8 zP#cK5JPHSw;V;{Yji=55X~S+)%(8fuz}O>*F3)hR;STU`z6T1aM#Wd+FP(M5*@T1P z^06O;I20Sk!bxW<-O;E081KRdHZrtsGJflFRRFS zdi5w9OVDGSL3 zNrC7GVsGN=b;YH9jp8Z2$^!K@h=r-xV(aEH@#JicPy;A0k1>g1g^XeR`YV2HfmqXY zYbRwaxHvf}OlCAwHoVI&QBLr5R|THf?nAevV-=~V8;gCsX>jndvNOcFA+DI+zbh~# zZ7`qNk&w+_+Yp!}j;OYxIfx_{f0-ONc?mHCiCUak=>j>~>YR4#w# zuKz~UhT!L~GfW^CPqG8Lg)&Rc6y^{%3H7iLa%^l}cw_8UuG;8nn9)kbPGXS}p3!L_ zd#9~5CrH8xtUd?{d2y^PJg+z(xIfRU;`}^=OlehGN2=?}9yH$4Rag}*+AWotyxfCJ zHx=r7ZH>j2kV?%7WTtp+-HMa0)_*DBBmC{sd$)np&GEJ__kEd`xB5a2A z*J+yx>4o#ZxwA{;NjhU*1KT~=ZK~GAA;KZHDyBNTaWQ1+;tOFFthnD)DrCn`DjBZ% zk$N5B4^$`n^jNSOr=t(zi8TN4fpaccsb`zOPD~iY=UEK$0Y70bG{idLx@IL)7^(pL z{??Bnu=lDeguDrd%qW1)H)H`9otsOL-f4bSu};o9OXybo6J!Lek`a4ff>*O)BDT_g z<6@SrI|C9klY(>_PfA^qai7A_)VNE4c^ZjFcE$Isp>`e5fLc)rg@8Q_d^Uk24$2bn z9#}6kZ2ZxS9sI(RqT7?El2@B+($>eBQrNi_k#CDJ8D9}8$mmm z4oSKO^F$i+NG)-HE$O6s1--6EzJa?C{x=QgK&c=)b(Q9OVoAXYEEH20G|q$}Hue%~ zO3B^bF=t7t48sN zWh_zA`w~|){-!^g?6Mqf6ieV zFx~aPUOJGR=4{KsW7I?<=J2|lY`NTU=lt=%JE9H1vBpkcn=uq(q~=?iBt_-r(PLBM zP-0dxljJO>4Wq-;stY)CLB4q`-r*T$!K2o}?E-w_i>3_aEbA^MB7P5piwt1dI-6o!qWCy0 ztYy!x9arGTS?kabkkyv*yxvsPQ7Vx)twkS6z2T@kZ|kb8yjm+^$|sEBmvACeqbz)RmxkkDQX-A*K!YFziuhwb|ym>C$}U|J)4y z$(z#)GH%uV6{ec%Zy~AhK|+GtG8u@c884Nq%w`O^wv2#A(&xH@c5M`Vjk*SR_tJnq z0trB#aY)!EKW_}{#L3lph5ow=@|D5LzJYUFD6 z7XnUeo_V0DVSIKMFD_T0AqAO|#VFDc7c?c-Q%#u00F%!_TW1@JVnsfvm@_9HKWflBOUD~)RL``-!P;(bCON_4eVdduMO>?IrQ__*zE@7(OX zUtfH@AX*53&xJW*Pu9zcqxGiM>xol0I~QL5B%Toog3Jlenc^WbVgeBvV8C8AX^Vj& z^I}H})B=VboO%q1;aU5ACMh{yK4J;xlMc`jCnZR^!~LDs_MP&8;dd@4LDWw~*>#OT zeZHwdQWS!tt5MJQI~cw|Ka^b4c|qyd_ly(+Ql2m&AAw^ zQeSXDOOH!!mAgzAp0z)DD>6Xo``b6QwzUV@w%h}Yo>)a|xRi$jGuHQhJVA%>)PUvK zBQ!l0hq<3VZ*RnrDODP)>&iS^wf64C;MGqDvx>|p;35%6(u+IHoNbK z;Gb;TneFo*`zUKS6kwF*&b!U8e5m4YAo03a_e^!5BP42+r)LFhEy?_7U1IR<; z^0v|DhCYMSj<-;MtY%R@Fg;9Kky^pz_t2nJfKWfh5Eu@_l{^ph%1z{jkg5jQrkvD< z#vdK!nku*RrH~TdN~`wDs;d>XY1PH?O<4^U4lmA|wUW{Crrv#r%N>7k#{Gc44Fr|t z@UZP}Y-TrAmnEZ39A*@6;ccsR>)$A)S>$-Cj!=x$rz7IvjHIPM(TB+JFf{ehuIvY$ zsDAwREg*%|=>Hw$`us~RP&3{QJg%}RjJKS^mC_!U;E5u>`X`jW$}P`Mf}?7G7FX#{ zE(9u1SO;3q@ZhDL9O({-RD+SqqPX)`0l5IQu4q)49TUTkxR(czeT}4`WV~pV*KY&i zAl3~X%D2cPVD^B43*~&f%+Op)wl<&|D{;=SZwImydWL6@_RJjxP2g)s=dH)u9Npki zs~z9A+3fj0l?yu4N0^4aC5x)Osnm0qrhz@?nwG_`h(71P znbIewljU%T*cC=~NJy|)#hT+lx#^5MuDDnkaMb*Efw9eThXo|*WOQzJ*#3dmRWm@! zfuSc@#kY{Um^gBc^_Xdxnl!n&y&}R4yAbK&RMc+P^Ti;YIUh|C+K1|=Z^{nZ}}rxH*v{xR!i%qO~o zTr`WDE@k$M9o0r4YUFFeQO7xCu_Zgy)==;fCJ94M_rLAv&~NhfvcLWCoaGg2ao~3e zBG?Ms9B+efMkp}7BhmISGWmJsKI@a8b}4lLI48oWKY|8?zuuNc$lt5Npr+p7a#sWu zh!@2nnLBVJK!$S~>r2-pN||^w|fY`CT{TFnJy`B|e5;=+_v4l8O-fkN&UQbA4NKTyntd zqK{xEKh}U{NHoQUf!M=2(&w+eef77VtYr;xs%^cPfKLObyOV_9q<(%76-J%vR>w9!us-0c-~Y?_EVS%v!* z15s2s3eTs$Osz$JayyH|5nPAIPEX=U;r&p;K14G<1)bvn@?bM5kC{am|C5%hyxv}a z(DeSKI5ZfZ1*%dl8frIX2?);R^^~LuDOpNpk-2R8U1w92HmG1m&|j&J{EK=|p$;f9 z7Rs5|jr4r8k5El&qcuM+YRlKny%t+1CgqEWO>3;BSRZi(LA3U%Jm{@{y+A+w(gzA< z7dBq6a1sEWa4cD0W7=Ld9z0H7RI^Z7vl(bfA;72j?SWCo`#5mVC$l1Q2--%V)-uN* z9ha*s-AdfbDZ8R8*fpwjzx=WvOtmSzGFjC#X)hD%Caeo^OWjS(3h|d9_*U)l%{Ab8 zfv$yoP{OuUl@$(-sEVNt{*=qi5P=lpxWVuz2?I7Dc%BRc+NGNw+323^ z5BXGfS71oP^%apUo(Y#xkxE)y?>BFzEBZ}UBbr~R4$%b7h3iZu3S(|A;&HqBR{nK& z$;GApNnz=kNO^FL&nYcfpB7Qg;hGJPsCW44CbkG1@l9pn0`~oKy5S777uH)l{irK!ru|X+;4&0D;VE*Ii|<3P zUx#xUqvZT5kVQxsF#~MwKnv7;1pR^0;PW@$@T7I?s`_rD1EGUdSA5Q(C<>5SzE!vw z;{L&kKFM-MO>hy#-8z`sdVx})^(Dc-dw;k-h*9O2_YZw}|9^y-|8RQ`BWJUJL(Cer zP5Z@fNc>pTXABbTRY-B5*MphpZv6#i802giwV&SkFCR zGMETyUm(KJbh+&$8X*RB#+{surjr;8^REEt`2&Dubw3$mx>|~B5IKZJ`s_6fw zKAZx9&PwBqW1Oz0r0A4GtnZd7XTKViX2%kPfv+^X3|_}RrQ2e3l=KG_VyY`H?I5&CS+lAX5HbA%TD9u6&s#v!G> zzW9n4J%d5ye7x0y`*{KZvqyXUfMEE^ZIffzI=Hh|3J}^yx7eL=s+TPH(Q2GT-sJ~3 zI463C{(ag7-hS1ETtU;_&+49ABt5!A7CwLwe z=SoA8mYZIQeU;9txI=zcQVbuO%q@E)JI+6Q!3lMc=Gbj(ASg-{V27u>z2e8n;Nc*pf}AqKz1D>p9G#QA+7mqqrEjGfw+85Uyh!=tTFTv3|O z+)-kFe_8FF_EkTw!YzwK^Hi^_dV5x-Ob*UWmD-})qKj9@aE8g240nUh=g|j28^?v7 zHRTBo{0KGaWBbyX2+lx$wgXW{3aUab6Bhm1G1{jTC7ota*JM6t+qy)c5<@ zpc&(jVdTJf(q3xB=JotgF$X>cxh7k*(T`-V~AR+`%e?YOeALQ2Qud( zz35YizXt(aW3qndR}fTw1p()Ol4t!D1pitGNL95{SX4ywzh0SF;=!wf=?Q?_h6!f* zh7<+GFi)q|XBsvXZ^qVCY$LUa{5?!CgwY?EG;*)0ceFe&=A;!~o`ae}Z+6me#^sv- z1F6=WNd6>M(~ z+092z>?Clrcp)lYNQl9jN-JF6n&Y0mp7|I0dpPx+4*RRK+VQI~>en0Dc;Zfl+x z_e_b7s`t1_A`RP3$H}y7F9_na%D7EM+**G_Z0l_nwE+&d_kc35n$Fxkd4r=ltRZhh zr9zER8>j(EdV&Jgh(+i}ltESBK62m0nGH6tCBr90!4)-`HeBmz54p~QP#dsu%nb~W z7sS|(Iydi>C@6ZM(Us!jyIiszMkd)^u<1D+R@~O>HqZIW&kearPWmT>63%_t2B{_G zX{&a(gOYJx!Hq=!T$RZ&<8LDnxsmx9+TBL0gTk$|vz9O5GkK_Yx+55^R=2g!K}NJ3 zW?C;XQCHZl7H`K5^BF!Q5X2^Mj93&0l_O3Ea3!Ave|ixx+~bS@Iv18v2ctpSt4zO{ zp#7pj!AtDmti$T`e9{s^jf(ku&E|83JIJO5Qo9weT6g?@vX!{7)cNwymo1+u(YQ94 zopuz-L@|5=h8A!(g-MXgLJC0MA|CgQF8qlonnu#j z;uCeq9ny9QSD|p)9sp3ebgY3rk#y0DA(SHdh$DUm^?GI<>%e1?&}w(b zdip1;P2Z=1wM+$q=TgLP$}svd!vk+BZ@h<^4R=GS2+sri7Z*2f`9 z5_?i)xj?m#pSVchk-SR!2&uNhzEi+#5t1Z$o0PoLGz*pT64%+|Wa+rd5Z}60(j?X= z{NLjtgRb|W?CUADqOS@(*MA-l|E342NxRaxLTDqsOyfWWe%N(jjBh}G zm7WPel6jXijaTiNita+z(5GCO0NM=Melxud57PP^d_U## zbA;9iVi<@wr0DGB8=T9Ab#2K_#zi=$igyK48@;V|W`fg~7;+!q8)aCOo{HA@vpSy-4`^!ze6-~8|QE||hC{ICKllG9fbg_Y7v z$jn{00!ob3!@~-Z%!rSZ0JO#@>|3k10mLK0JRKP-Cc8UYFu>z93=Ab-r^oL2 zl`-&VBh#=-?{l1TatC;VweM^=M7-DUE>m+xO7Xi6vTEsReyLs8KJ+2GZ&rxw$d4IT zPXy6pu^4#e;;ZTsgmG+ZPx>piodegkx2n0}SM77+Y*j^~ICvp#2wj^BuqRY*&cjmL zcKp78aZt>e{3YBb4!J_2|K~A`lN=u&5j!byw`1itV(+Q_?RvV7&Z5XS1HF)L2v6ji z&kOEPmv+k_lSXb{$)of~(BkO^py&7oOzpjdG>vI1kcm_oPFHy38%D4&A4h_CSo#lX z2#oqMCTEP7UvUR3mwkPxbl8AMW(e{ARi@HCYLPSHE^L<1I}OgZD{I#YH#GKnpRmW3 z2jkz~Sa(D)f?V?$gNi?6)Y;Sm{&?~2p=0&BUl_(@hYeX8YjaRO=IqO7neK0RsSNdYjD zaw$g2sG(>JR=8Iz1SK4`*kqd_3-?;_BIcaaMd^}<@MYbYisWZm2C2|Np_l|8r9yM|JkUngSo@?wci(7&O9a z%|V(4C1c9pps0xxzPbXH=}QTxc2rr7fXk$9`a6TbWKPCz&p=VsB8^W96W=BsB|7bc zf(QR8&Ktj*iz)wK&mW`#V%4XTM&jWNnDF56O+2bo<3|NyUhQ%#OZE8$Uv2a@J>D%t zMVMiHh?es!Ex19q&6eC&L=XDU_BA&uR^^w>fpz2_`U87q_?N2y;!Z!bjoeKrzfC)} z?m^PM=(z{%n9K`p|7Bz$LuC7!>tFOuN74MFELm}OD9?%jpT>38J;=1Y-VWtZAscaI z_8jUZ#GwWz{JqvGEUmL?G#l5E=*m>`cY?m*XOc*yOCNtpuIGD+Z|kn4Xww=BLrNYS zGO=wQh}Gtr|7DGXLF%|`G>J~l{k^*{;S-Zhq|&HO7rC_r;o`gTB7)uMZ|WWIn@e0( zX$MccUMv3ABg^$%_lNrgU{EVi8O^UyGHPNRt%R!1#MQJn41aD|_93NsBQhP80yP<9 zG4(&0u7AtJJXLPcqzjv`S~5;Q|5TVGccN=Uzm}K{v)?f7W!230C<``9(64}D2raRU zAW5bp%}VEo{4Rko`bD%Ehf=0voW?-4Mk#d3_pXTF!-TyIt6U+({6OXWVAa;s-`Ta5 zTqx&8msH3+DLrVmQOTBOAj=uoxKYT3DS1^zBXM?1W+7gI!aQNPYfUl{3;PzS9*F7g zWJN8x?KjBDx^V&6iCY8o_gslO16=kh(|Gp)kz8qlQ`dzxQv;)V&t+B}wwdi~uBs4? zu~G|}y!`3;8#vIMUdyC7YEx6bb^1o}G!Jky4cN?BV9ejBfN<&!4M)L&lRKiuMS#3} z_B}Nkv+zzxhy{dYCW$oGC&J(Ty&7%=5B$sD0bkuPmj7g>|962`(Q{ZZMDv%YMuT^KweiRDvYTEop3IgFv#)(w>1 zSzH>J`q!LK)c(AK>&Ib)A{g`Fdykxqd`Yq@yB}E{gnQV$K!}RsgMGWqC3DKE(=!{}ekB3+(1?g}xF>^icEJbc z5bdxAPkW90atZT+&*7qoLqL#p=>t-(-lsnl2XMpZcYeW|o|a322&)yO_8p(&Sw{|b zn(tY$xn5yS$DD)UYS%sP?c|z>1dp!QUD)l;aW#`%qMtQJjE!s2z`+bTSZmLK7SvCR z=@I4|U^sCwZLQSfd*ACw9B@`1c1|&i^W_OD(570SDLK`MD0wTiR8|$7+%{cF&){$G zU~|$^Ed?TIxyw{1$e|D$050n8AjJvvOWhLtLHbSB|HIfjMp+gu>DraHZJRrdO53(= z+o-f{+qNog+qSLB%KY;5>Av6X(>-qYk3IIEwZ5~6a+P9lMpC^ z8CJ0q>rEpjlsxCvJm=kms@tlN4+sv}He`xkr`S}bGih4t`+#VEIt{1veE z{ZLtb_pSbcfcYPf4=T1+|BtR!x5|X#x2TZEEkUB6kslKAE;x)*0x~ES0kl4Dex4e- zT2P~|lT^vUnMp{7e4OExfxak0EE$Hcw;D$ehTV4a6hqxru0$|Mo``>*a5=1Ym0u>BDJKO|=TEWJ5jZu!W}t$Kv{1!q`4Sn7 zrxRQOt>^6}Iz@%gA3&=5r;Lp=N@WKW;>O!eGIj#J;&>+3va^~GXRHCY2}*g#9ULab zitCJt-OV0*D_Q3Q`p1_+GbPxRtV_T`jyATjax<;zZ?;S+VD}a(aN7j?4<~>BkHK7bO8_Vqfdq1#W&p~2H z&w-gJB4?;Q&pG9%8P(oOGZ#`!m>qAeE)SeL*t8KL|1oe;#+uOK6w&PqSDhw^9-&Fa zuEzbi!!7|YhlWhqmiUm!muO(F8-F7|r#5lU8d0+=;<`{$mS=AnAo4Zb^{%p}*gZL! zeE!#-zg0FWsSnablw!9$<&K(#z!XOW z;*BVx2_+H#`1b@>RtY@=KqD)63brP+`Cm$L1@ArAddNS1oP8UE$p05R=bvZoYz+^6 z<)!v7pRvi!u_-V?!d}XWQR1~0q(H3{d^4JGa=W#^Z<@TvI6J*lk!A zZ*UIKj*hyO#5akL*Bx6iPKvR3_2-^2mw|Rh-3O_SGN3V9GRo52Q;JnW{iTGqb9W99 z7_+F(Op6>~3P-?Q8LTZ-lwB}xh*@J2Ni5HhUI3`ct|*W#pqb>8i*TXOLn~GlYECIj zhLaa_rBH|1jgi(S%~31Xm{NB!30*mcsF_wgOY2N0XjG_`kFB+uQuJbBm3bIM$qhUyE&$_u$gb zpK_r{99svp3N3p4yHHS=#csK@j9ql*>j0X=+cD2dj<^Wiu@i>c_v zK|ovi7}@4sVB#bzq$n3`EgI?~xDmkCW=2&^tD5RuaSNHf@Y!5C(Is$hd6cuyoK|;d zO}w2AqJPS`Zq+(mc*^%6qe>1d&(n&~()6-ZATASNPsJ|XnxelLkz8r1x@c2XS)R*H(_B=IN>JeQUR;T=i3<^~;$<+8W*eRKWGt7c#>N`@;#!`kZ!P!&{9J1>_g8Zj zXEXxmA=^{8A|3=Au+LfxIWra)4p<}1LYd_$1KI0r3o~s1N(x#QYgvL4#2{z8`=mXy zQD#iJ0itk1d@Iy*DtXw)Wz!H@G2St?QZFz zVPkM%H8Cd2EZS?teQN*Ecnu|PrC!a7F_XX}AzfZl3fXfhBtc2-)zaC2eKx*{XdM~QUo4IwcGgVdW69 z1UrSAqqMALf^2|(I}hgo38l|Ur=-SC*^Bo5ej`hb;C$@3%NFxx5{cxXUMnTyaX{>~ zjL~xm;*`d08bG_K3-E+TI>#oqIN2=An(C6aJ*MrKlxj?-;G zICL$hi>`F%{xd%V{$NhisHSL~R>f!F7AWR&7b~TgLu6!3s#~8|VKIX)KtqTH5aZ8j zY?wY)XH~1_a3&>#j7N}0az+HZ;is;Zw(Am{MX}YhDTe(t{ZZ;TG}2qWYO+hdX}vp9 z@uIRR8g#y~-^E`Qyem(31{H0&V?GLdq9LEOb2(ea#e-$_`5Q{T%E?W(6 z(XbX*Ck%TQM;9V2LL}*Tf`yzai{0@pYMwBu%(I@wTY!;kMrzcfq0w?X`+y@0ah510 zQX5SU(I!*Fag4U6a7Lw%LL;L*PQ}2v2WwYF(lHx_Uz2ceI$mnZ7*eZ?RFO8UvKI0H z9Pq-mB`mEqn6n_W9(s~Jt_D~j!Ln9HA)P;owD-l~9FYszs)oEKShF9Zzcmnb8kZ7% zQ`>}ki1kwUO3j~ zEmh140sOkA9v>j@#56ymn_RnSF`p@9cO1XkQy6_Kog?0ivZDb`QWOX@tjMd@^Qr(p z!sFN=A)QZm!sTh(#q%O{Ovl{IxkF!&+A)w2@50=?a-+VuZt6On1;d4YtUDW{YNDN_ zG@_jZi1IlW8cck{uHg^g=H58lPQ^HwnybWy@@8iw%G! zwB9qVGt_?~M*nFAKd|{cGg+8`+w{j_^;nD>IrPf-S%YjBslSEDxgKH{5p)3LNr!lD z4ii)^%d&cCXIU7UK?^ZQwmD(RCd=?OxmY(Ko#+#CsTLT;p#A%{;t5YpHFWgl+@)N1 zZ5VDyB;+TN+g@u~{UrWrv)&#u~k$S&GeW)G{M#&Di)LdYk?{($Cq zZGMKeYW)aMtjmKgvF0Tg>Mmkf9IB#2tYmH-s%D_9y3{tfFmX1BSMtbe<(yqAyWX60 zzkgSgKb3c{QPG2MalYp`7mIrYg|Y<4Jk?XvJK)?|Ecr+)oNf}XLPuTZK%W>;<|r+% zTNViRI|{sf1v7CsWHvFrkQ$F7+FbqPQ#Bj7XX=#M(a~9^80}~l-DueX#;b}Ajn3VE z{BWI}$q{XcQ3g{(p>IOzFcAMDG0xL)H%wA)<(gl3I-oVhK~u_m=hAr&oeo|4lZbf} z+pe)c34Am<=z@5!2;_lwya;l?xV5&kWe}*5uBvckm(d|7R>&(iJNa6Y05SvlZcWBlE{{%2- z`86)Y5?H!**?{QbzGG~|k2O%eA8q=gxx-3}&Csf6<9BsiXC)T;x4YmbBIkNf;0Nd5 z%whM^!K+9zH>on_<&>Ws?^v-EyNE)}4g$Fk?Z#748e+GFp)QrQQETx@u6(1fk2!(W zWiCF~MomG*y4@Zk;h#2H8S@&@xwBIs|82R*^K(i*0MTE%Rz4rgO&$R zo9Neb;}_ulaCcdn3i17MO3NxzyJ=l;LU*N9ztBJ30j=+?6>N4{9YXg$m=^9@Cl9VY zbo^{yS@gU=)EpQ#;UIQBpf&zfCA;00H-ee=1+TRw@(h%W=)7WYSb5a%$UqNS@oI@= zDrq|+Y9e&SmZrH^iA>Of8(9~Cf-G(P^5Xb%dDgMMIl8gk6zdyh`D3OGNVV4P9X|EvIhplXDld8d z^YWtYUz@tpg*38Xys2?zj$F8%ivA47cGSl;hjD23#*62w3+fwxNE7M7zVK?x_`dBSgPK zWY_~wF~OEZi9|~CSH8}Xi>#8G73!QLCAh58W+KMJJC81{60?&~BM_0t-u|VsPBxn* zW7viEKwBBTsn_A{g@1!wnJ8@&h&d>!qAe+j_$$Vk;OJq`hrjzEE8Wjtm)Z>h=*M25 zOgETOM9-8xuuZ&^@rLObtcz>%iWe%!uGV09nUZ*nxJAY%&KAYGY}U1WChFik7HIw% zZP$3Bx|TG_`~19XV7kfi2GaBEhKap&)Q<9`aPs#^!kMjtPb|+-fX66z3^E)iwyXK7 z8)_p<)O{|i&!qxtgBvWXx8*69WO$5zACl++1qa;)0zlXf`eKWl!0zV&I`8?sG)OD2Vy?reNN<{eK+_ za4M;Hh%&IszR%)&gpgRCP}yheQ+l#AS-GnY81M!kzhWxIR?PW`G3G?} z$d%J28uQIuK@QxzGMKU_;r8P0+oIjM+k)&lZ39i#(ntY)*B$fdJnQ3Hw3Lsi8z&V+ zZly2}(Uzpt2aOubRjttzqrvinBFH4jrN)f0hy)tj4__UTwN)#1fj3-&dC_Vh7}ri* zfJ=oqLMJ-_<#rwVyN}_a-rFBe2>U;;1(7UKH!$L??zTbbzP#bvyg7OQBGQklJ~DgP zd<1?RJ<}8lWwSL)`jM53iG+}y2`_yUvC!JkMpbZyb&50V3sR~u+lok zT0uFRS-yx@8q4fPRZ%KIpLp8R#;2%c&Ra4p(GWRT4)qLaPNxa&?8!LRVdOUZ)2vrh zBSx&kB%#Y4!+>~)<&c>D$O}!$o{<1AB$M7-^`h!eW;c(3J~ztoOgy6Ek8Pwu5Y`Xion zFl9fb!k2`3uHPAbd(D^IZmwR5d8D$495nN2`Ue&`W;M-nlb8T-OVKt|fHk zBpjX$a(IR6*-swdNk@#}G?k6F-~c{AE0EWoZ?H|ZpkBxqU<0NUtvubJtwJ1mHV%9v?GdDw; zAyXZiD}f0Zdt-cl9(P1la+vQ$Er0~v}gYJVwQazv zH#+Z%2CIfOf90fNMGos|{zf&N`c0@x0N`tkFv|_9af3~<0z@mnf*e;%r*Fbuwl-IW z{}B3=(mJ#iwLIPiUP`J3SoP~#)6v;aRXJ)A-pD2?_2_CZ#}SAZ<#v7&Vk6{*i(~|5 z9v^nC`T6o`CN*n%&9+bopj^r|E(|pul;|q6m7Tx+U|UMjWK8o-lBSgc3ZF=rP{|l9 zc&R$4+-UG6i}c==!;I#8aDIbAvgLuB66CQLRoTMu~jdw`fPlKy@AKYWS-xyZzPg&JRAa@m-H43*+ne!8B7)HkQY4 zIh}NL4Q79a-`x;I_^>s$Z4J4-Ngq=XNWQ>yAUCoe&SMAYowP>r_O}S=V+3=3&(O=h zNJDYNs*R3Y{WLmBHc?mFEeA4`0Y`_CN%?8qbDvG2m}kMAiqCv`_BK z_6a@n`$#w6Csr@e2YsMx8udNWtNt=kcqDZdWZ-lGA$?1PA*f4?X*)hjn{sSo8!bHz zb&lGdAgBx@iTNPK#T_wy`KvOIZvTWqSHb=gWUCKXAiB5ckQI`1KkPx{{%1R*F2)Oc z(9p@yG{fRSWE*M9cdbrO^)8vQ2U`H6M>V$gK*rz!&f%@3t*d-r3mSW>D;wYxOhUul zk~~&ip5B$mZ~-F1orsq<|1bc3Zpw6)Ws5;4)HilsN;1tx;N6)tuePw& z==OlmaN*ybM&-V`yt|;vDz(_+UZ0m&&9#{9O|?0I|4j1YCMW;fXm}YT$0%EZ5^YEI z4i9WV*JBmEU{qz5O{#bs`R1wU%W$qKx?bC|e-iS&d*Qm7S=l~bMT{~m3iZl+PIXq{ zn-c~|l)*|NWLM%ysfTV-oR0AJ3O>=uB-vpld{V|cWFhI~sx>ciV9sPkC*3i0Gg_9G!=4ar*-W?D9)?EFL1=;O+W8}WGdp8TT!Fgv z{HKD`W>t(`Cds_qliEzuE!r{ihwEv1l5o~iqlgjAyGBi)$%zNvl~fSlg@M=C{TE;V zQkH`zS8b&!ut(m)%4n2E6MB>p*4(oV>+PT51#I{OXs9j1vo>9I<4CL1kv1aurV*AFZ^w_qfVL*G2rG@D2 zrs87oV3#mf8^E5hd_b$IXfH6vHe&lm@7On~Nkcq~YtE!}ad~?5*?X*>y`o;6Q9lkk zmf%TYonZM`{vJg$`lt@MXsg%*&zZZ0uUSse8o=!=bfr&DV)9Y6$c!2$NHyYAQf*Rs zk{^?gl9E z5Im8wlAsvQ6C2?DyG@95gUXZ3?pPijug25g;#(esF_~3uCj3~94}b*L>N2GSk%Qst z=w|Z>UX$m!ZOd(xV*2xvWjN&c5BVEdVZ0wvmk)I+YxnyK%l~caR=7uNQ=+cnNTLZ@&M!I$Mj-r{!P=; z`C2)D=VmvK8@T5S9JZoRtN!S*D_oqOxyy!q6Zk|~4aT|*iRN)fL)c>-yycR>-is0X zKrko-iZw(f(!}dEa?hef5yl%p0-v-8#8CX8!W#n2KNyT--^3hq6r&`)5Y@>}e^4h- zlPiDT^zt}Ynk&x@F8R&=)k8j$=N{w9qUcIc&)Qo9u4Y(Ae@9tA`3oglxjj6c{^pN( zQH+Uds2=9WKjH#KBIwrQI%bbs`mP=7V>rs$KG4|}>dxl_k!}3ZSKeEen4Iswt96GGw`E6^5Ov)VyyY}@itlj&sao|>Sb5 zeY+#1EK(}iaYI~EaHQkh7Uh>DnzcfIKv8ygx1Dv`8N8a6m+AcTa-f;17RiEed>?RT zk=dAksmFYPMV1vIS(Qc6tUO+`1jRZ}tcDP? zt)=7B?yK2RcAd1+Y!$K5*ds=SD;EEqCMG6+OqPoj{&8Y5IqP(&@zq@=A7+X|JBRi4 zMv!czlMPz)gt-St2VZwDD=w_S>gRpc-g zUd*J3>bXeZ?Psjohe;z7k|d<*T21PA1i)AOi8iMRwTBSCd0ses{)Q`9o&p9rsKeLaiY zluBw{1r_IFKR76YCAfl&_S1*(yFW8HM^T()&p#6y%{(j7Qu56^ZJx1LnN`-RTwimdnuo*M8N1ISl+$C-%=HLG-s} zc99>IXRG#FEWqSV9@GFW$V8!{>=lSO%v@X*pz*7()xb>=yz{E$3VE;e)_Ok@A*~El zV$sYm=}uNlUxV~6e<6LtYli1!^X!Ii$L~j4e{sI$tq_A(OkGquC$+>Rw3NFObV2Z)3Rt~Jr{oYGnZaFZ^g5TDZlg;gaeIP} z!7;T{(9h7mv{s@piF{-35L=Ea%kOp;^j|b5ZC#xvD^^n#vPH=)lopYz1n?Kt;vZmJ z!FP>Gs7=W{sva+aO9S}jh0vBs+|(B6Jf7t4F^jO3su;M13I{2rd8PJjQe1JyBUJ5v zcT%>D?8^Kp-70bP8*rulxlm)SySQhG$Pz*bo@mb5bvpLAEp${?r^2!Wl*6d7+0Hs_ zGPaC~w0E!bf1qFLDM@}zso7i~(``)H)zRgcExT_2#!YOPtBVN5Hf5~Ll3f~rWZ(UsJtM?O*cA1_W0)&qz%{bDoA}{$S&-r;0iIkIjbY~ zaAqH45I&ALpP=9Vof4OapFB`+_PLDd-0hMqCQq08>6G+C;9R~}Ug_nm?hhdkK$xpI zgXl24{4jq(!gPr2bGtq+hyd3%Fg%nofK`psHMs}EFh@}sdWCd!5NMs)eZg`ZlS#O0 zru6b8#NClS(25tXqnl{|Ax@RvzEG!+esNW-VRxba(f`}hGoqci$U(g30i}2w9`&z= zb8XjQLGN!REzGx)mg~RSBaU{KCPvQx8)|TNf|Oi8KWgv{7^tu}pZq|BS&S<53fC2K4Fw6>M^s$R$}LD*sUxdy6Pf5YKDbVet;P!bw5Al-8I1Nr(`SAubX5^D9hk6$agWpF}T#Bdf{b9-F#2WVO*5N zp+5uGgADy7m!hAcFz{-sS0kM7O)qq*rC!>W@St~^OW@R1wr{ajyYZq5H!T?P0e+)a zaQ%IL@X_`hzp~vRH0yUblo`#g`LMC%9}P;TGt+I7qNcBSe&tLGL4zqZqB!Bfl%SUa z6-J_XLrnm*WA`34&mF+&e1sPCP9=deazrM=Pc4Bn(nV;X%HG^4%Afv4CI~&l!Sjzb z{rHZ3od0!Al{}oBO>F*mOFAJrz>gX-vs!7>+_G%BB(ljWh$252j1h;9p~xVA=9_`P z5KoFiz96_QsTK%B&>MSXEYh`|U5PjX1(+4b#1PufXRJ*uZ*KWdth1<0 zsAmgjT%bowLyNDv7bTUGy|g~N34I-?lqxOUtFpTLSV6?o?<7-UFy*`-BEUsrdANh} zBWkDt2SAcGHRiqz)x!iVoB~&t?$yn6b#T=SP6Ou8lW=B>=>@ik93LaBL56ub`>Uo!>0@O8?e)$t(sgy$I z6tk3nS@yFFBC#aFf?!d_3;%>wHR;A3f2SP?Na8~$r5C1N(>-ME@HOpv4B|Ty7%jAv zR}GJwsiJZ5@H+D$^Cwj#0XA_(m^COZl8y7Vv(k=iav1=%QgBOVzeAiw zaDzzdrxzj%sE^c9_uM5D;$A_7)Ln}BvBx^=)fO+${ou%B*u$(IzVr-gH3=zL6La;G zu0Kzy5CLyNGoKRtK=G0-w|tnwI)puPDOakRzG(}R9fl7#<|oQEX;E#yCWVg95 z;NzWbyF&wGg_k+_4x4=z1GUcn6JrdX4nOVGaAQ8#^Ga>aFvajQN{!+9rgO-dHP zIp@%&ebVg}IqnRWwZRTNxLds+gz2@~VU(HI=?Epw>?yiEdZ>MjajqlO>2KDxA>)cj z2|k%dhh%d8SijIo1~20*5YT1eZTDkN2rc^zWr!2`5}f<2f%M_$to*3?Ok>e9$X>AV z2jYmfAd)s|(h?|B(XYrIfl=Wa_lBvk9R1KaP{90-z{xKi+&8=dI$W0+qzX|ZovWGOotP+vvYR(o=jo?k1=oG?%;pSqxcU* zWVGVMw?z__XQ9mnP!hziHC`ChGD{k#SqEn*ph6l46PZVkm>JF^Q{p&0=MKy_6apts z`}%_y+Tl_dSP(;Ja&sih$>qBH;bG;4;75)jUoVqw^}ee=ciV;0#t09AOhB^Py7`NC z-m+ybq1>_OO+V*Z>dhk}QFKA8V?9Mc4WSpzj{6IWfFpF7l^au#r7&^BK2Ac7vCkCn{m0uuN93Ee&rXfl1NBY4NnO9lFUp zY++C1I;_{#OH#TeP2Dp?l4KOF8ub?m6zE@XOB5Aiu$E~QNBM@;r+A5mF2W1-c7>ex zHiB=WJ&|`6wDq*+xv8UNLVUy4uW1OT>ey~Xgj@MMpS@wQbHAh>ysYvdl-1YH@&+Q! z075(Qd4C!V`9Q9jI4 zSt{HJRvZec>vaL_brKhQQwbpQd4_Lmmr0@1GdUeU-QcC{{8o=@nwwf>+dIKFVzPriGNX4VjHCa zTbL9w{Y2V87c2ofX%`(48A+4~mYTiFFl!e{3K^C_k%{&QTsgOd0*95KmWN)P}m zTRr{`f7@=v#+z_&fKYkQT!mJn{*crj%ZJz#(+c?>cD&2Lo~FFAWy&UG*Op^pV`BR^I|g?T>4l5;b|5OQ@t*?_Slp`*~Y3`&RfKD^1uLezIW(cE-Dq2z%I zBi8bWsz0857`6e!ahet}1>`9cYyIa{pe53Kl?8|Qg2RGrx@AlvG3HAL-^9c^1GW;)vQt8IK+ zM>!IW*~682A~MDlyCukldMd;8P|JCZ&oNL(;HZgJ>ie1PlaInK7C@Jg{3kMKYui?e!b`(&?t6PTb5UPrW-6DVU%^@^E`*y-Fd(p|`+JH&MzfEq;kikdse ziFOiDWH(D< zyV7Rxt^D0_N{v?O53N$a2gu%1pxbeK;&ua`ZkgSic~$+zvt~|1Yb=UfKJW2F7wC^evlPf(*El+#}ZBy0d4kbVJsK- z05>;>?HZO(YBF&v5tNv_WcI@O@LKFl*VO?L(!BAd!KbkVzo;v@~3v`-816GG?P zY+H3ujC>5=Am3RIZDdT#0G5A6xe`vGCNq88ZC1aVXafJkUlcYmHE^+Z{*S->ol%-O znm9R0TYTr2w*N8Vs#s-5=^w*{Y}qp5GG)Yt1oLNsH7y~N@>Eghms|K*Sdt_u!&I}$ z+GSdFTpbz%KH+?B%Ncy;C`uW6oWI46(tk>r|5|-K6)?O0d_neghUUOa9BXHP*>vi; z={&jIGMn-92HvInCMJcyXwHTJ42FZp&Wxu+9Rx;1x(EcIQwPUQ@YEQQ`bbMy4q3hP zNFoq~Qd0=|xS-R}k1Im3;8s{BnS!iaHIMLx)aITl)+)?Yt#fov|Eh>}dv@o6R{tG>uHsy&jGmWN5+*wAik|78(b?jtysPHC#e+Bzz~V zS3eEXv7!Qn4uWi!FS3B?afdD*{fr9>B~&tc671fi--V}~E4un;Q|PzZRwk-azprM$4AesvUb5`S`(5x#5VJ~4%ET6&%GR$}muHV-5lTsCi_R|6KM(g2PCD@|yOpKluT zakH!1V7nKN)?6JmC-zJoA#ciFux8!)ajiY%K#RtEg$gm1#oKUKX_Ms^%hvKWi|B=~ zLbl-L)-=`bfhl`>m!^sRR{}cP`Oim-{7}oz4p@>Y(FF5FUEOfMwO!ft6YytF`iZRq zfFr{!&0Efqa{1k|bZ4KLox;&V@ZW$997;+Ld8Yle91he{BfjRhjFTFv&^YuBr^&Pe zswA|Bn$vtifycN8Lxr`D7!Kygd7CuQyWqf}Q_PM}cX~S1$-6xUD%-jrSi24sBTFNz(Fy{QL2AmNbaVggWOhP;UY4D>S zqKr!UggZ9Pl9Nh_H;qI`-WoH{ceXj?m8y==MGY`AOJ7l0Uu z)>M%?dtaz2rjn1SW3k+p`1vs&lwb%msw8R!5nLS;upDSxViY98IIbxnh{}mRfEp=9 zbrPl>HEJeN7J=KnB6?dwEA6YMs~chHNG?pJsEj#&iUubdf3JJwu=C(t?JpE6xMyhA3e}SRhunDC zn-~83*9=mADUsk^sCc%&&G1q5T^HR9$P#2DejaG`Ui*z1hI#h7dwpIXg)C{8s< z%^#@uQRAg-$z&fmnYc$Duw63_Zopx|n{Bv*9Xau{a)2%?H<6D>kYY7_)e>OFT<6TT z0A}MQLgXbC2uf`;67`mhlcUhtXd)Kbc$PMm=|V}h;*_%vCw4L6r>3Vi)lE5`8hkSg zNGmW-BAOO)(W((6*e_tW&I>Nt9B$xynx|sj^ux~?q?J@F$L4;rnm_xy8E*JYwO-02u9_@@W0_2@?B@1J{y~Q39N3NX^t7#`=34Wh)X~sU&uZWgS1Z09%_k|EjA4w_QqPdY`oIdv$dJZ;(!k)#U8L+|y~gCzn+6WmFt#d{OUuKHqh1-uX_p*Af8pFYkYvKPKBxyid4KHc}H` z*KcyY;=@wzXYR{`d{6RYPhapShXIV?0cg_?ahZ7do)Ot#mxgXYJYx}<%E1pX;zqHd zf!c(onm{~#!O$2`VIXezECAHVd|`vyP)Uyt^-075X@NZDBaQt<>trA3nY-Dayki4S zZ^j6CCmx1r46`4G9794j-WC0&R9(G7kskS>=y${j-2;(BuIZTLDmAyWTG~`0)Bxqk zd{NkDe9ug|ms@0A>JVmB-IDuse9h?z9nw!U6tr7t-Lri5H`?TjpV~8(gZWFq4Vru4 z!86bDB;3lpV%{rZ`3gtmcRH1hjj!loI9jN>6stN6A*ujt!~s!2Q+U1(EFQEQb(h4E z6VKuRouEH`G6+8Qv2C)K@^;ldIuMVXdDDu}-!7FS8~k^&+}e9EXgx~)4V4~o6P^52 z)a|`J-fOirL^oK}tqD@pqBZi_;7N43%{IQ{v&G9^Y^1?SesL`;Z(dt!nn9Oj5Odde%opv&t zxJ><~b#m+^KV&b?R#)fRi;eyqAJ_0(nL*61yPkJGt;gZxSHY#t>ATnEl-E%q$E16% zZdQfvhm5B((y4E3Hk6cBdwGdDy?i5CqBlCVHZr-rI$B#>Tbi4}Gcvyg_~2=6O9D-8 zY2|tKrNzbVR$h57R?Pe+gUU_il}ZaWu|Az#QO@};=|(L-RVf0AIW zq#pO+RfM7tdV`9lI6g;{qABNId`fG%U9Va^ravVT^)CklDcx)YJKeJdGpM{W1v8jg z@&N+mR?BPB=K1}kNwXk_pj44sd>&^;d!Z~P>O78emE@Qp@&8PyB^^4^2f7e)gekMv z2aZNvP@;%i{+_~>jK7*2wQc6nseT^n6St9KG#1~Y@$~zR_=AcO2hF5lCoH|M&c{vR zSp(GRVVl=T*m~dIA;HvYm8HOdCkW&&4M~UDd^H)`p__!4k+6b)yG0Zcek8OLw$C^K z3-BbLiG_%qX|ZYpXJ$(c@aa7b4-*IQkDF}=gZSV`*ljP|5mWuHSCcf$5qqhZTv&P?I$z^>}qP(q!Aku2yA5vu38d8x*q{6-1`%PrE_r0-9Qo?a#7Zbz#iGI7K<(@k^|i4QJ1H z4jx?{rZbgV!me2VT72@nBjucoT zUM9;Y%TCoDop?Q5fEQ35bCYk7!;gH*;t9t-QHLXGmUF;|vm365#X)6b2Njsyf1h9JW#x$;@x5Nx2$K$Z-O3txa%;OEbOn6xBzd4n4v)Va=sj5 z%rb#j7{_??Tjb8(Hac<^&s^V{yO-BL*uSUk2;X4xt%NC8SjO-3?;Lzld{gM5A=9AV z)DBu-Z8rRvXXwSVDH|dL-3FODWhfe1C_iF``F05e{dl(MmS|W%k-j)!7(ARkV?6r~ zF=o42y+VapxdZn;GnzZfGu<6oG-gQ7j7Zvgo7Am@jYxC2FpS@I;Jb%EyaJDBQC(q% zKlZ}TVu!>;i3t~OAgl@QYy1X|T~D{HOyaS*Bh}A}S#a9MYS{XV{R-|niEB*W%GPW! zP^NU(L<}>Uab<;)#H)rYbnqt|dOK(-DCnY==%d~y(1*{D{Eo1cqIV8*iMfx&J*%yh zx=+WHjt0q2m*pLx8=--UqfM6ZWjkev>W-*}_*$Y(bikH`#-Gn#!6_ zIA&kxn;XYI;eN9yvqztK-a113A%97in5CL5Z&#VsQ4=fyf&3MeKu70)(x^z_uw*RG zo2Pv&+81u*DjMO6>Mrr7vKE2CONqR6C0(*;@4FBM;jPIiuTuhQ-0&C)JIzo_k>TaS zN_hB;_G=JJJvGGpB?uGgSeKaix~AkNtYky4P7GDTW6{rW{}V9K)Cn^vBYKe*OmP!; zohJs=l-0sv5&pL6-bowk~(swtdRBZQHh8)m^r2+qTtZ zt4m$B?OQYNyfBA0E)g28a*{)a=%%f-?{F;++-Xs#5|7kSHTD*E9@$V ztE%7zX4A(L`n)FY8Y4pOnKC|Pf)j$iR#yP;V0+|Hki+D;t4I4BjkfdYliK9Gf6RYw z;3px$Ud5aTd`yq$N7*WOs!{X91hZZ;AJ9iQOH%p;v$R%OQum_h#rq9*{ve(++|24z zh2P;{-Z?u#rOqd0)D^_Ponv(Y9KMB9#?}nJdUX&r_rxF0%3__#8~ZwsyrSPmtWY27 z-54ZquV2t_W!*+%uwC=h-&_q~&nQer0(FL74to%&t^byl^C?wTaZ-IS9OssaQFP)1 zAov0o{?IRAcCf+PjMWSdmP42gysh|c9Ma&Q^?_+>>+-yrC8WR;*XmJ;>r9v*>=W}tgWG;WIt{~L8`gk8DP{dSdG z4SDM7g5ahMHYHHk*|mh9{AKh-qW7X+GEQybJt9A@RV{gaHUAva+=lSroK^NUJYEiL z?X6l9ABpd)9zzA^;FdZ$QQs#uD@hdcaN^;Q=AXlbHv511Meye`p>P4Y2nblEDEeZo}-$@g&L98Aih6tgLz--${eKTxymIipy0xSYgZZ zq^yyS4yNPTtPj-sM?R8@9Q1gtXPqv{$lb5i|C1yymwnGdfYV3nA-;5!Wl zD0fayn!B^grdE?q^}ba{-LIv*Z}+hZm_F9c$$cW!bx2DgJD&6|bBIcL@=}kQA1^Eh zXTEznqk)!!IcTl>ey?V;X8k<+C^DRA{F?T*j0wV`fflrLBQq!l7cbkAUE*6}WabyF zgpb+|tv=aWg0i}9kBL8ZCObYqHEycr5tpc-$|vdvaBsu#lXD@u_e1iL z{h>xMRS0a7KvW?VttrJFpX^5DC4Bv4cp6gNG6#8)7r7IxXfSNSp6)_6tZ4l>(D+0I zPhU)N!sKywaBusHdVE!yo5$20JAU8V_XcW{QmO!p*~ns8{2~bhjydnmA&=r zX9NSM9QYogYMDZ~kS#Qx`mt>AmeR3p@K$`fbJ%LQ1c5lEOz<%BS<}2DL+$>MFcE%e zlxC)heZ7#i80u?32eOJI9oQRz0z;JW@7Th4q}YmQ-`Z?@y3ia^_)7f37QMwDw~<-@ zT)B6fftmK_6YS!?{uaj5lLxyR++u*ZY2Mphm5cd7PA5=%rd)95hJ9+aGSNfjy>Ylc zoI0nGIT3sKmwX8h=6CbvhVO+ehFIR155h8iRuXZx^cW>rq5K4z_dvM#hRER=WR@THs%WELI9uYK9HN44Em2$#@k)hD zicqRPKV#yB;UlcsTL_}zCMK0T;eXHfu`y2(dfwm(v)IBbh|#R>`2cot{m7}8_X&oD zr@94PkMCl%d3FsC4pil=#{3uv^+)pvxfwmPUr)T)T|GcZVD$wVj$mjkjDs`5cm8N! zXVq2CvL;gWGpPI4;9j;2&hS*o+LNp&C5Ac=OXx*W5y6Z^az)^?G0)!_iAfjH5wiSE zD(F}hQZB#tF5iEx@0sS+dP70DbZ*<=5X^)Pxo^8aKzOzuyc2rq=<0-k;Y_ID1>9^v z+)nc36}?>jen*1%OX3R*KRASj${u$gZ$27Hpcj=95kK^aLzxhW6jj_$w6}%#1*$5D zG1H_vYFrCSwrRqYw*9<}OYAOQT)u%9lC`$IjZV<4`9Sc;j{Qv_6+uHrYifK&On4V_7yMil!0Yv55z@dFyD{U@Sy>|vTX=P_( zRm<2xj*Z}B30VAu@0e+}at*y?wXTz|rPalwo?4ZZc>hS0Ky6~mi@kv#?xP2a;yt?5=(-CqvP_3&$KdjB7Ku;# z`GLE*jW1QJB5d&E?IJO?1+!Q8HQMGvv^RuFoi=mM4+^tOqvX%X&viB%Ko2o-v4~~J z267ui;gsW?J=qS=D*@*xJvAy3IOop5bEvfR4MZC>9Y4Z$rGI|EHNNZ7KX;Ix{xSvm z-)Cau-xuTm|7`4kUdXvd_d^E=po(76ELfq5OgxIt3aqDy#zBfIy-5<3gpn{Ce`-ha z<;6y@{Bgqw?c~h*&j{FozQCh=`Lv-5Iw!KdSt;%GDOq%=(V!dJ-}|}|0o5G2kJj6{ z`jCSPs$9Fe8O(+qALZiJ$WtR=<@GvsdM)IJ`7XrBfW0iyYE#Vy^e@zbysg*B5Z_kSL6<)vqoaH zQ{!9!*{e9UZo^h+qZ`T@LfVwAEwc&+9{C8c%oj41q#hyn<&zA9IIur~V|{mmu`n5W z8)-Ou$YgjQ*PMIqHhZ_9E?(uoK0XM5aQkarcp}WT^7b^FC#^i>#8LGZ9puDuXUYas z7caX)V5U6uY-L5Wl%)j$qRkR;7@3T*N64YK_!`Fw=>CAwe~2loI1<>DZW&sb7Q)X;6E08&$h! z2=c1i4UOO{R4TmkTz+o9n`}+%d%blR6P;5{`qjtxlN$~I%tMMDCY`~e{+mRF!rj5( z3ywv)P_PUUqREu)TioPkg&5RKjY6z%pRxQPQ{#GNMTPag^S8(8l{!{WGNs2U1JA-O zq02VeYcArhTAS;v3);k(&6ayCH8SXN@r;1NQeJ*y^NHM+zOd;?t&c!Hq^SR_w6twGV8dl>j zjS+Zc&Yp7cYj&c1y3IxQ%*kWiYypvoh(k8g`HrY<_Bi-r%m-@SLfy-6mobxkWHxyS z>TtM2M4;Uqqy|+8Q++VcEq$PwomV1D4UzNA*Tgkg9#Gpz#~&iPf|Czx!J?qss?e|3 z4gTua75-P{2X7w9eeK3~GE0ip-D;%%gTi)8bR~Ez@)$gpuS~jZs`CrO5SR-Xy7bkA z89fr~mY}u4A$|r1$fe-;T{yJh#9Ime1iRu8eo?uY9@yqAU3P!rx~SsP;LTBL zeoMK(!;(Zt8313 z3)V)q_%eflKW?BnMZa}6E0c7t!$-mC$qt44OME5F(6B$E8w*TUN-h}0dOiXI+TH zYFrr&k1(yO(|J0vP|{22@Z}bxm@7BkjO)f)&^fv|?_JX+s)1*|7X7HH(W?b3QZ3!V|~m?8}uJsF>NvE4@fik zjyyh+U*tt`g6v>k9ub88a;ySvS1QawGn7}aaR**$rJA=a#eUT~ngUbJ%V=qsFIekLbv!YkqjTG{_$F;$w19$(ivIs*1>?2ka%uMOx@B9`LD zhm~)z@u4x*zcM1WhiX)!U{qOjJHt1xs{G1S?rYe)L)ntUu^-(o_dfqZu)}W(X%Uu| zN*qI@&R2fB#Jh|Mi+eMrZDtbNvYD3|v0Kx>E#Ss;Be*T$@DC!2A|mb%d}TTN3J+c= zu@1gTOXFYy972S+=C;#~)Z{Swr0VI5&}WYzH22un_Yg5o%f9fvV(`6!{C<(ZigQ2`wso)cj z9O12k)15^Wuv#rHpe*k5#4vb%c znP+Gjr<-p%01d<+^yrSoG?}F=eI8X;?=Fo2a~HUiJ>L!oE#9tXRp!adg-b9D;(6$E zeW0tH$US04zTX$OxM&X+2ip>KdFM?iG_fgOD-qB|uFng8*#Z5jgqGY=zLU?4!OlO#~YBTB9b9#~H@nqQ#5 z6bV));d?IJTVBC+79>rGuy1JgxPLy$dA7;_^^L)02m}XLjFR*qH`eI~+eJo(7D`LH z(W%lGnGK+Vk_3kyF*zpgO=1MxMg?hxe3}}YI>dVs8l}5eWjYu4=w6MWK09+05 zGdpa#$awd>Q|@aZa*z{5F3xy3n@E4YT9%TmMo0jxW59p0bI?&S}M+ z&^NG%rf7h*m9~p#b19|`wO5OMY-=^XT+=yrfGNpl<&~~FGsx_`IaFn+sEgF$hgOa~oAVAiu^a$jHcqkE=dj`ze z=axsfrzzh6VGD0x#6Ff=t%+VTiq!n6^gv*uIUD<9fOhvR;al5kcY${uunn}-!74<7 zmP^3cl-kyN(QY!!Z-^PY-OUkh=3ZWk6>le$_Q&xk4cgH{?i)C%2RM@pX5Q{jdSlo! zVau5v44cQX5|zQlQDt;dCg)oM0B<=P1CR!W%!^m$!{pKx;bn9DePJjWBX)q!`$;0K zqJIIyD#aK;#-3&Nf=&IhtbV|?ZGYHSphp~6th`p2rkw&((%kBV7<{siEOU7AxJj+FuRdDu$ zcmTW8usU_u!r)#jg|J=Gt{##7;uf4A5cdt6Y02}f(d2)z~ z)CH~gVAOwBLk$ZiIOn}NzDjvfw(w$u|BdCBI#)3xB-Ot?nz?iR38ayCm48M=_#9r7 zw8%pwQ<9mbEs5~_>pN3~#+Er~Q86J+2TDXM6umCbukd-X6pRIr5tF?VauT8jW> zY^#)log>jtJs2s3xoiPB7~8#1ZMv>Zx0}H58k-@H2huNyw~wsl0B8j)H5)H9c7y&i zp8^0;rKbxC1eEZ-#Qxvz)Xv$((8lK9I>BspPajluysw^f#t9P;OUis43mmEzX+lk* zc4T-Ms9_687GR+~QS#0~vxK#DSGN=a-m(@eZTqw2<+lN9>R~gK2)3;sT4%nI%Y|0m zX9SPR!>?~s=j5H4WMqeTW8QaLZ=1bWS5I3xZ&$(ypc=tHrv+hX@s)VG(tc!yvLM7n zshN=C#v={X1r;)xn0Pow_1eMhkn!{;x$BJ#PIz)m585&%cmzk;btQzZAN_^zis;n? z?6I~bN?s;7vg_dtoTc4A5Ow*Rb}No#UYl)sN|RmoYo}k^cKLXd8F`44?RrokkPvN5 ztUrx;U~B;jbE_qGd3n0j2i}A{enJvJ?gSF~NQj~EP5vM-w4@;QQ5n(Npic}XNW6B0 zq9F4T%6kp7qGhd0vpQcz+nMk8GOAmbz8Bt4@GtewGr6_>Xj>ge)SyfY}nu>Y!a@HoIx(StD zx`!>RT&}tpBL%nOF%7XIFW?n1AP*xthCMzhrU6G!U6?m4!CPWTvn#Yaoi_95CT2!L z|B=5zeRW30&ANGN>J9#GtCm&3SF6n4TqDz<-{@ZXkrkRDCpV$DwCtI^e&3i1A{Ar&JZtS^c+lyPa6 z%JJr42S_;eFC#M~bdtQePhOU32WDiZ4@H&af)z#$Y|hnQNb)8(3?1Ad>5uaZ1z zU~!jt3XUI@gpWb8tWTyH7DGvKvzYfqNIy3P{9vpwz_C-QL&`+8Io$F5PS-@YQJoEO z17D9P(+sXajWSH_8&C?fn>rTLX+(?KiwX#JNV)xE0!Q@>Tid$V2#r4y6fkph?YZ>^ z(o^q(0*P->3?I0cELXJn(N|#qTm6 zAPIL~n)m!50;*?5=MOOc4Wk;w(0c$(!e?vpV23S|n|Y7?nyc8)fD8t-KI&nTklH&BzqQ}D(1gH3P+5zGUzIjT~x`;e8JH=86&5&l-DP% z)F+Et(h|GJ?rMy-Zrf>Rv@<3^OrCJ1xv_N*_@-K5=)-jP(}h1Rts44H&ou8!G_C1E zhTfUDASJ2vu!4@j58{NN;78i?6__xR75QEDC4JN{>RmgcNrn-EOpEOcyR<8FS@RB@ zH!R7J=`KK^u06eeI|X@}KvQmdKE3AmAy8 zM4IIvde#e4O(iwag`UL5yQo>6&7^=D4yE-Eo9$9R2hR} zn;Z9i-d=R-xZl4@?s%8|m1M`$J6lW1r0Y)+8q$}Vn4qyR1jqTjGH;@Z!2KiGun2~x zaiEfzVT<|_b6t}~XPeflAm8hvCHP3Bp*tl{^y_e{Jsn@w+KP{7}bH_s=1S2E1sj=18a39*Ag~lbkT^_OQuYQey=b zW^{0xlQ@O$^cSxUZ8l(Mspg8z0cL*?yH4;X2}TdN)uN31A%$3$a=4;{S@h#Y(~i%) zc=K7Ggl=&2hYVic*W65gpSPE70pU;FN@3k?BYdNDKv6wlsBAF^);qiqI zhklsX4TaWiC%VbnZ|yqL+Pcc;(#&E*{+Rx&<&R{uTYCn^OD|mAk4%Q7gbbgMnZwE{ zy7QMK%jIjU@ye?0; z;0--&xVeD}m_hq9A8a}c9WkI2YKj8t!Mkk!o%AQ?|CCBL9}n570}OmZ(w)YI6#QS&p<={tcek*D{CPR%eVA1WBGUXf z%gO2vL7iVDr1$!LAW)1@H>GoIl=&yyZ7=*9;wrOYQ}O}u>h}4FWL?N2ivURlUi11- zl{G0fo`9?$iAEN<4kxa#9e0SZPqa{pw?K=tdN5tRc7HDX-~Ta6_+#s9W&d`6PB7dF*G@|!Mc}i zc=9&T+edI(@la}QU2An#wlkJ&7RmTEMhyC_A8hWM54?s1WldCFuBmT5*I3K9=1aj= z6V@93P-lUou`xmB!ATp0(We$?)p*oQs;(Kku15~q9`-LSl{(Efm&@%(zj?aK2;5}P z{6<@-3^k^5FCDT@Z%XABEcuPoumYkiD&)-8z2Q}HO9OVEU3WM;V^$5r4q>h^m73XF z5!hZ7SCjfxDcXyj(({vg8FU(m2_}36L_yR>fnW)u=`1t@mPa76`2@%8v@2@$N@TE` z)kYhGY1jD;B9V=Dv1>BZhR9IJmB?X9Wj99f@MvJ2Fim*R`rsRilvz_3n!nPFLmj({EP!@CGkY5R*Y_dSO{qto~WerlG}DMw9k+n}pk z*nL~7R2gB{_9=zpqX|*vkU-dx)(j+83uvYGP?K{hr*j2pQsfXn<_As6z%-z+wFLqI zMhTkG>2M}#BLIOZ(ya1y8#W<+uUo@(43=^4@?CX{-hAuaJki(_A(uXD(>`lzuM~M;3XA48ZEN@HRV{1nvt?CV)t;|*dow0Ue2`B*iA&!rI`fZQ=b28= z_dxF}iUQ8}nq0SA4NK@^EQ%=)OY;3fC<$goJ&Kp|APQ@qVbS-MtJQBc)^aO8mYFsbhafeRKdHPW&s^&;%>v zlTz`YE}CuQ@_X&mqm{+{!h2r)fPGeM_Ge4RRYQkrma`&G<>RW<>S(?#LJ}O-t)d$< zf}b0svP^Zu@)MqwEV^Fb_j zPYYs~vmEC~cOIE6Nc^@b@nyL!w5o?nQ!$mGq(Pa|1-MD}K0si<&}eag=}WLSDO zE4+eA~!J(K}605x&4 zT72P7J^)Y)b(3g2MZ@1bv%o1ggwU4Yb!DhQ=uu-;vX+Ix8>#y6wgNKuobvrPNx?$3 zI{BbX<=Y-cBtvY&#MpGTgOLYU4W+csqWZx!=AVMb)Z;8%#1*x_(-)teF>45TCRwi1 z)Nn>hy3_lo44n-4A@=L2gI$yXCK0lPmMuldhLxR8aI;VrHIS{Dk}yp= zwjhB6v@0DN=Hnm~3t>`CtnPzvA*Kumfn5OLg&-m&fObRD};c}Hf?n&mS< z%$wztc%kjWjCf-?+q(bZh9k~(gs?i4`XVfqMXvPVkUWfm4+EBF(nOkg!}4u)6I)JT zU6IXqQk?p1a2(bz^S;6ZH3Wy9!JvbiSr7%c$#G1eK2^=~z1WX+VW)CPD#G~)13~pX zErO(>x$J_4qu-)lNlZkLj2}y$OiKn0ad5Imu5p-2dnt)(YI|b7rJ3TBUQ8FB8=&ym50*ibd2NAbj z;JA&hJ$AJlldM+tO;Yl3rBOFiP8fDdF?t(`gkRpmT9inR@uX{bThYNmxx-LN5K8h0 ztS%w*;V%b`%;-NARbNXn9he&AO4$rvmkB#;aaOx?Wk|yBCmN{oMTK&E)`s&APR<-5 z#;_e75z;LJ)gBG~h<^`SGmw<$Z3p`KG|I@7Pd)sTJnouZ1hRvm3}V+#lPGk4b&A#Y z4VSNi8(R1z7-t=L^%;*;iMTIAjrXl;h106hFrR{n9o8vlz?+*a1P{rEZ2ie{luQs} zr6t746>eoqiO5)^y;4H%2~&FT*Qc*9_oC2$+&syHWsA=rn3B~4#QEW zf4GT3i_@)f(Fj}gAZj`7205M8!B&HhmbgyZB& z+COyAVNxql#DwfP;H48Yc+Y~ChV6b9auLnfXXvpjr<~lQ@>VbCpQvWz=lyVf1??_c zAo3C^otZD@(v?X)UX*@w?TF|F8KF>l7%!Dzu+hksSA^akEkx8QD(V(lK+HBCw6C}2onVExW)f$ zncm*HI(_H;jF@)6eu}Tln!t?ynRkcqBA5MitIM@L^(4_Ke}vy7c%$w{(`&7Rn=u>oDM+Z^RUYcbSOPwT(ONyq76R>$V6_M_UP4vs=__I#io{{((| zy5=k=oVr-Qt$FImP~+&sN8rf2UH*vRMpwohPc@9?id17La4weIfBNa>1Djy+1=ugn z@}Zs;eFY1OC}WBDxDF=i=On_33(jWE-QYV)HbQ^VM!n>Ci9_W0Zofz7!m>do@KH;S z4k}FqEAU2)b%B_B-QcPnM5Zh=dQ+4|DJoJwo?)f2nWBuZE@^>a(gP~ObzMuyNJTgJFUPcH`%9UFA(P23iaKgo0)CI!SZ>35LpFaD7 z)C2sW$ltSEYNW%%j8F;yK{iHI2Q^}coF@LX`=EvxZb*_O;2Z0Z5 z7 zlccxmCfCI;_^awp|G748%Wx%?t9Sh8!V9Y(9$B?9R`G)Nd&snX1j+VpuQ@GGk=y(W zK|<$O`Cad`Y4#W3GKXgs%lZduAd1t1<7LwG4*zaStE*S)XXPFDyKdgiaVXG2)LvDn zf}eQ_S(&2!H0Mq1Yt&WpM1!7b#yt_ie7naOfX129_E=)beKj|p1VW9q>>+e$3@G$K zrB%i_TT1DHjOf7IQ8)Wu4#K%ZSCDGMP7Ab|Kvjq7*~@ewPm~h_-8d4jmNH<&mNZC@CI zKxG5O08|@<4(6IEC@L-lcrrvix&_Dj4tBvl=8A}2UX|)~v#V$L22U}UHk`B-1MF(t zU6aVJWR!>Y0@4m0UA%Sq9B5;4hZvsOu=>L`IU4#3r_t}os|vSDVMA??h>QJ1FD1vR z*@rclvfD!Iqoxh>VP+?b9TVH8g@KjYR@rRWQy44A`f6doIi+8VTP~pa%`(Oa@5?=h z8>YxNvA##a3D0)^P|2|+0~f|UsAJV=q(S>eq-dehQ+T>*Q@qN zU8@kdpU5gGk%ozt?%c8oM6neA?GuSsOfU_b1U)uiEP8eRn~>M$p*R z43nSZs@^ahO78s zulbK@@{3=2=@^yZ)DuIC$ki;`2WNbD_#`LOHN9iMsrgzt-T<8aeh z(oXrqI$Kgt6)Icu=?11NWs>{)_ed1wh>)wv6RYNUA-C&bejw{cBE_5Wzeo!AHdTd+ z)d(_IKN7z^n|As~3XS=cCB_TgM7rK;X586re`{~Foml$aKs zb!4Pe7hEP|370EWwn$HKPM!kL94UPZ1%8B^e5fB+=Iw^6=?5n3tZGYjov83CLB&OQ++p)WCMeshCv_9-~G9C_2x`LxTDjUcW$l6e!6-&a^fM3oP9*g(H zmCk0nGt1UMdU#pfg1G0um5|sc|KO<+qU1E4iBF~RvN*+`7uNHH^gu{?nw2DSCjig% zI@ymKZSK=PhHJa(jW&xeApv&JcfSmNJ4uQ|pY=Lcc>=J|{>5Ug3@x#R_b@55xFgfs za^ANzWdD$ZYtFs$d7+oiw0ZmPk2&l|< zc8()wfiJx@EGpQT zG$8iLkQZ-086doF1R zh<#9cz_vRsJdoXbD=QgOtpm}cFAJX8c}>Jew;PQJSXSb^;wlC zxXLHTS|!GZ-VK_4wV<9bk4RUmlsByzW_^b>)$6R+jQ}^wco1nMA`9Lncs;&QGp!`5Tx#aXXU?}5_RrtUY zx(EMzDhl-a^y^f5yfFLMnOO#u)l69&4M?|ne|2EV>zQ}4JQCBel?~2I4?D|>L$%H(peOOII!U}i z-j)*h1rODe9{0`xmhG;`AKqw1p0_KhEIU8)DoGnEn9wAhXPaxO_(jNSij~J5m$P*$ z9Mt(t;eV}2+i|kjQpBFcNb7_(VbuF<;RQB~R~p>2*Lg>a&7DEEuq*I%Ls4{zHeUDq z+M0&YhEn^C*9-B4Q7HJ$xj)dORCXPK+)ZtLOa0o&)Sl+f(Y{p*68$-#yagW5^HQnQ z0pWpoQpxg8<&gx9im(>=x6v#&RbQ7^AsjxeSDA? zi4MEJUC~ByG!PiBjq7$pK&FA^5 z=Y@dtQnuy%IfsaR`TVP0q^3mixl&J-3!$H!ua#{A>0Z1JdLq#d4UV9nlYm641ZHl zH6mK~iI6lR3OUEVL}Z5{ONZ_6{Nk%Bv03ag<1HVN?R%w2^aR5@E>6(r>}IoMl$wRF zWr-DItN*k7T$NTT8B)+23c?171sADhjInb2Xb>GhFYGC&3{b>huvLlaS4O z^{j5q+b5H?Z)yuy%AByaVl2yj9cnalY1sMQ zXI#e%*CLajxGxP!K6xf9RD2pMHOfAa1d^Lr6kE`IBpxOiGXfNcoQ*FI6wsNtLD!T+ zC4r2q>5qz0f}UY^RY#1^0*FPO*Zp-U1h9U|qWjwqJaDB(pZ`<`U-xo7+JB$zvwV}^ z2>$0&Q5k#l|Er7*PPG1ycj4BGz zg&`d*?nUi1Q!OB>{V@T$A;)8@h;*Rb1{xk_8X<34L`s}xkH-rQZvjM`jI=jaJRGRg zeEcjYChf-78|RLrao%4HyZBfnAx5KaE~@Sx+o-2MLJ>j-6uDb!U`odj*=)0k)K75l zo^)8-iz{_k7-_qy{Ko~N#B`n@o#A22YbKiA>0f3k=p-B~XX=`Ug>jl$e7>I=hph0&AK z?ya;(NaKY_!od=tFUcGU5Kwt!c9EPUQLi;JDCT*{90O@Wc>b| zI;&GIY$JlQW^9?R$-OEUG|3sp+hn+TL(YK?S@ZW<4PQa}=IcUAn_wW3d!r#$B}n08 z*&lf(YN21NDJ74DqwV`l`RX(4zJ<(E4D}N0@QaE-hnfdPDku~@yhb^AeZL73RgovX z6=e>!`&e^l@1WA5h!}}PwwL*Gjg!LbC5g0|qb8H$^S{eGs%cc?4vTyVFW=s6KtfW? z@&Xm+E(uz(qDbwDvRQI9DdB<2sW}FYK9sg*f%-i*>*n{t-_wXvg~N7gM|a91B!x|K zyLbJ~6!!JZpZ`#HpCB8g#Q*~VU47Rp$NyZb3WhEgg3ivSwnjGJgi0BEV?!H}Z@QF| zrO`Kx*52;FR#J-V-;`oR-pr!t>bYf)UYcixN=(FUR6$fhN@~i09^3WeP3*)D*`*mJ z1u%klAbzQ=P4s%|FnVTZv%|@(HDB+ap5S#cFSJUSGkyI*Y>9Lwx|0lTs%uhoCW(f1 zi+|a9;vDPfh3nS<7m~wqTM6+pEm(&z-Ll;lFH!w#(Uk#2>Iv~2Hu}lITn7hnOny`~ z*Vj=r<&Nwpq^@g5m`u&QTBRoK*}plAuHg$L$~NO#wF0!*r0OfcS%)k0A??uY*@B^C zJe9WdU(w){rTIf<;rwJt^_35^d<A@$FqEZW6kwyfAo2x0T$Ye2MZox6Z7<%Qbu$}}u{rtE+h2M+Z}T4I zxF1cwJ(Uvp!T#mogWkhb(?SxD4_#tV(Sc8N4Gu*{Fh#})Pvb^ef%jrlnG*&Ie+J5 zsly5oo?1((um&lLDxn(DkYtk`My>lgKTp3Y4?hTQ4_`YNOFtjF-FUY#d#(EQd(rfz zB8z%Vi;?x)ZM$3c>yc5H8KBvSevnWNdCbAj?QCac)6-K~Xz@EZp}~N9q)5*Ufjz3C z6kkOeI{3H(^VO8hKDrVjy2DXd;5wr4nb`19yJi0DO@607MSx+7F$ zz3F7sl8JV@@sM$6`#JmSilqI%Bs)}Py2eFT;TjcG5?8$zwV60b(_5A>b#uk~7U^bO z>y|6SCrP2IGST(8HFuX|XQUXPLt2gL_hm|uj1Ws`O2VW>SyL^uXkl>Zvkcpi?@!F7 z%svLoT@{R#XrIh^*dE~$YhMwC+b7JE09NAS47kT%Ew zD!XjxA@1+KOAyu`H2z#h+pGm!lG>WI0v745l+Fd><3dh{ATq%h?JSdEt zu%J*zfFUx%Tx&0DS5WSbE)vwZSoAGT=;W#(DoiL($BcK;U*w`xA&kheyMLI673HCb7fGkp{_vdV2uo;vSoAH z9BuLM#Vzwt#rJH>58=KXa#O;*)_N{$>l7`umacQ0g$pI3iW4=L--O;Wiq0zy7OKp`j2r^y3`7X!?sq9rr5B{41BkBr1fEd1#Q3 z-dXc2RSb4U>FvpVhlQCIzQ-hs=8420z=7F2F(^xD;^RXgpjlh8S6*xCP#Gj2+Q0bAg?XARw3dnlQ*Lz3vk}m`HXmCgN=?bIL{T zi}Ds-xn|P)dxhraT@XY$ZQ&^%x8y!o+?n#+>+dZ1c{hYwNTNRke@3enT(a@}V*X{! z81+{Jc2UR;+Zcbc6cUlafh4DFKwp>;M}8SGD+YnW3Q_)*9Z_pny_z+MeYQmz?r%EVaN0d!NE*FVPq&U@vo{ef6wkMIDEWLbDs zz91$($XbGnQ?4WHjB~4xgPgKZts{p|g1B{-4##}#c5aL5C6_RJ_(*5>85B1}U!_<``}q-97Q7~u)(&lsb(WT^(*n7H%33%@_b zO5(?-v??s??33b19xiB7t_YT!q8!qAzN1#RD@3;kYAli%kazt#YN7}MhVu=ljuz27 z1`<+g8oVwy57&$`CiHeaM)tz(OSt4E# zJ@P6E*e504oUw~RD(=9WP8QdW^6wRdFbKII!GAWecJ(?{`EzTR@?j!3g?$@LLCt;U={>!9z7DU!(1Jq zqEwdx5q?W1Ncm7mXP8MFwAr?nw5$H%cb>Q><9j{Tk2RY9ngGvaJgWXx^r!ywk{ph- zs2PFto4@IIwBh{oXe;yMZJYlS?3%a-CJ#js90hoh5W5d^OMwCFmpryHFr|mG+*ZP$ zqyS5BW@s}|3xUO0PR<^{a2M(gkP5BDGxvkWkPudSV*TMRK5Qm4?~VuqVAOerffRt$HGAvp;M++Iq$E6alB z;ykBr-eZ6v_H^1Wip56Czj&=`mb^TsX|FPN#-gnlP03AkiJDM=?y|LzER1M93R4sC z*HT(;EV=*F*>!+Z{r!KG?6ODMGvkt3viG=@kQJHNMYd}bS4KrrHf4`&*(0m0R5Hqz zEk)r=sFeS?MZRvn<@Z0&bDw)XkMnw+_xqgp=W{;ioX`6;G-P9N%wfoYJ$-m$L#MC% z^sH?tSzA|WWP(cN3({~_*X$l{M*;1V{l$;T6b){#l4pswDTid26HaXgKed}13YIP= zJRvA3nmx{}R$Lr&S4!kWU3`~dxM}>VXWu6Xd(VP}z1->h&f%82eXD_TuTs@=c;l0T z|LHmWKJ+?7hkY=YM>t}zvb4|lV;!ARMtWFp!E^J=Asu9w&kVF*i{T#}sY++-qnVh! z5TQ|=>)+vutf{&qB+LO9^jm#rD7E5+tcorr^Fn5Xb0B;)f^$7Ev#}G_`r==ea294V z--v4LwjswWlSq9ba6i?IXr8M_VEGQ$H%hCqJTFQ3+1B9tmxDUhnNU%dy4+zbqYJ|o z3!N{b?A@{;cG2~nb-`|z;gEDL5ffF@oc3`R{fGi)0wtMqEkw4tRX3t;LVS3-zAmg^ zgL7Z{hmdPSz9oA@t>tZ1<|Khn&Lp=_!Q=@a?k+t~H&3jN?dr(}7s;{L+jiKY57?WsFBfW^mu6a03_^VKrdK=9egXw@!nzZ3TbYc*osyQNoCXPYoFS<&Nr97MrQCOK(gO8 z;0@iqRTJy4-RH)PJld5`AJN}n?5r^-enKrHQOR;z>UMfm+e8~4ZL5k>oXMiYq12Bx4eVQv0jFgp_zC#``sjZpywYqISMP}VZ@!~1Mf$!x|opj%mQ98JnSk@`~ zPmmyuPZKtZOnEC!1y!?`TYRsZ!II;d!iln}%e}bk5qIiUADERr*K$3dekgHV9TtBX zi5q!J!6Zgd#cLxRmZN^J`o@Zv{+p+<_#8^nvY)44Hw_2i@?R&5n^q33fpOnDg1nPQ z_r<$hURl~OketX|Tdbvf_7=3x^rSFJtEp@tuDpVB&uq)qW;xUQ7mmkr-@eZwa$l+? zoKk``Vz@TH#>jMce*8>@FZ+@BEUdYa_K0i|{*;j9MW3K%pnM*T;@>|o@lMhgLrpZP5aol(z>g;b4}|e$U~Fn zGL%(}p%Jsl4LxE!VW_Y4T>e}W4e#~F03H_^R!Q)kpJG{lO!@I4{mFo^V#ayHh_5~o zB$O71gcE(G@6xv);#Ky?e(Ed}^O+Ho(t=93T9T3TnEY(OVf_dR-gY@jj+iJSY?q|6prBv(S9A4k=2fNZz!W@S=B@~b?TJRTuBQq448@juN#Y=3q=^VCF>Z}n6wICJ<^^Kn8C;mK zZYiFSN#Z$?NDGV7(#}q2tAZAtE63icK-MY>UQu4MWlGIbJ$AF8Zt-jV;@7P5MPI>% zPWvO!t%1+s>-A%`;0^o8Ezeaa4DMwI8ooQrJ;ax@Qt*6XONWw)dPwOPI9@u*EG&844*1~EoZ2qsAe~M>d`;Bc_CWY zMoDKEmDh-}k9d6*<0g@aQmsnrM1H9IcKYZs)><)d92{|0Hh8?~XbF)7U+UmP@Pw_6geVB?7N$4J4*E0z3EO&5kRS(EE zv92(+e5WxLXMN{h;-|8@!Q#0q247hb^3R%*k3MuMO5*L}$0D#5P*N$aHd54C+=_RToYXTyewugOaDmGsCvb4H1s=@gkfVnzTCWKMa-Mm1v4Wq!t-JIrbV&EWwKDe ze#kJpOq#iRlFz%5#6Fio9IUlKnQ#X&DY8Ux#<-WqxAac-y%U_L+EZZ4Rg5*yNg`f< zSZn&uio@zanUCPqX1l4W&B!;UWs#P7B^|4WwoCxQXl|44n^cBNqu=3Vl*ltAqsUQO z9q_@nD0zq0O8r`coEm>9+|rA3HL#l}X;0##>SJS$cVavOZVCpSGf4mUU1( zWaRCUYc^9QbG9=vpWo%xP}CMFnMb{reA`K7tT(t5DM)d9l}jVPY>qoRzT zE3m-p#=i=$9x*CB`AL>SY}u3agYFl#uULNen#&44H;!L@I{RI=PlWxG8J((f)ma7A z@jLvQ>?Nx`n?3ChRG#HqE3MXP8*o3!Qq`+t8EMt_p)oeKHqPusBxPn!#?R??-=e3e zo73WNs_IZF`WLigre=|`aS2^> zN1zn!7k&Dh28t%VpJ%**&E!eAcB5oLjQFFcJQj*URMia%Ya3@q1UQ18=oWMM6`I}iT_&L1gl?*~6nU4q4Z0`H<5yDp(HeZ+RGf9`mM&= zn-qRp%i!g$R;i1d1aMZ{IewNjE@p2+Z{`x{*xL*x$?WV~{BjJpsP&C&JK0HLoyf z`0z^v&fBQSa!I7FU~9MaQ%e|?RP>sM^2PL!mE^Q1Ig_4M$5BRfi72oMYu6Ke?wmDX z@0a%-V|z}b23K=ye(W+fG#w|jJUnT{=KR5jfuq!RX}<1irTDw(${<&}dWQu4;EuE< z@3u4dBkQaCHHM&;cE0z50_V!(vJ1_V)A8?C#eJuLkt!98Z%|Bgzidc0j|z(&o)TCzYlrgZA zC3@i>L!&Gw_~7`>puB97I2lK)lESZQqVXc_8T^G2O#VHhO?IC$g zOYhXJ7)~C<8l|Xrftka@QuowScM{K&0zskoU$Aw~vIRVRF9TEQ4*3=_5)98B`=t8(N%ZuWqmwlW zllAzq=E5_5!sKDXam@w`ZD(nl%LAPxQuEtDcKPqu9LPJvNIITawU#c^PQ2HmZgs)r zH^+gRwZ?0)8IFQgU)+p@0Iqb^tcEoqcB@zhfz_FaOM&_d<|jnU>q5nSKa<@%9|dje zIupcg1!tRiMP4X=oG<7s4|AW&^-Cw4FL9OuI$t zxjc*y;Uw!G7a|jz>E*2+PlR(CemWebS7m-&*CDwnmxbiRqJvQ&os-sC&4OWt^(2@vG4|jui#Df@-D= zh3D%8Y3R6+jRBStSvH9pt&tCI`NK08J1*pC(?OM0h!bS-JK3I}`pDY-fDIaB_*W6KS+TO0Q*%kkeuN6uWITt=TsCGw6uBE710q; zRluI%j{?@jwhM|l5&TB!-TkQs!A=DXRE>u18t@;zndD0M$U@Igrt?UW2; z7%=dsHIVH_LCkGUU0fW&UMjDnvjcc0Mp(mK&;d~ZJ5EJ)#7@aTZvGDFXzFZg2Lq~s z5PR_LazNN)JD5K_uK*Hy{mXuHTkGGv|9V8KP#iQ$3!G*^>7UiE{|1G1A-qg(xH;Xa>&%f|BZkH zG=J^0pHzSAqv5*5ysQ{Puy^-_|IPrii zKS$mE10Zngf>Sgg@BjpRyJbrHeo zD8Ro0LI*W#+9?^xlOS^c>Z^^n^0I|FH^@^`ZR`{H=$ zjO0_$cnpBM7Zcm?H_RXIu-Lu~qweDSV|tEZBZh!e6hQy->}e;d#osZ1hQj{HhHkC0 zJ|F-HKmeTGgDe979ogBz24;@<|I7;TU!IXb@oWMsMECIETmQy`zPtM`|NP}PjzR_u zKMG1Z{%1kWeMfEf(10U#w!clmQ2)JC8zm(Fv!H4dUHQHCFLikID?hrd{0>kCQt?kP zdqn2ZG0}ytcQJ7t_B3s0ZvH3PYjkjQ`Q%;jV@?MK-+z3etBCGGo4f4`y^|AdCs!DH zThTQ;cL5dM{|tB_1y6K3bVa^hx_<9J(}5`2SDz1^0bT!Vm*JV;9~t&{IC{$DUAVV* z{|E=#yN{wNdTY@$6z{_KNA3&%w|vFu1n9XRcM0Ak>`UW!lQ`ah3D4r%}Z literal 0 HcmV?d00001 diff --git a/lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.properties b/lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..bdc9a83b --- /dev/null +++ b/lib/java-server-sdk-ai/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lib/java-server-sdk-ai/gradlew b/lib/java-server-sdk-ai/gradlew new file mode 100755 index 00000000..79a61d42 --- /dev/null +++ b/lib/java-server-sdk-ai/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lib/java-server-sdk-ai/gradlew.bat b/lib/java-server-sdk-ai/gradlew.bat new file mode 100644 index 00000000..6689b85b --- /dev/null +++ b/lib/java-server-sdk-ai/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/java-server-sdk-ai/settings.gradle b/lib/java-server-sdk-ai/settings.gradle new file mode 100644 index 00000000..c7aa56e5 --- /dev/null +++ b/lib/java-server-sdk-ai/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'launchdarkly-java-server-sdk-ai' diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java new file mode 100644 index 00000000..9ffab32d --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java @@ -0,0 +1,476 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.ai.datamodel.AIAgentConfig; +import com.launchdarkly.sdk.server.ai.datamodel.AIAgentConfigDefault; +import com.launchdarkly.sdk.server.ai.datamodel.AIAgentConfigRequest; +import com.launchdarkly.sdk.server.ai.datamodel.AICompletionConfig; +import com.launchdarkly.sdk.server.ai.datamodel.AICompletionConfigDefault; +import com.launchdarkly.sdk.server.ai.datamodel.AIJudgeConfig; +import com.launchdarkly.sdk.server.ai.datamodel.AIJudgeConfigDefault; +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.LDTool; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.evaluation.Evaluator; +import com.launchdarkly.sdk.server.ai.internal.AISdkInfo; +import com.launchdarkly.sdk.server.ai.internal.Interpolator; +import com.launchdarkly.sdk.server.ai.internal.LDValueConversions; +import com.launchdarkly.sdk.server.ai.tracking.AIConfigTracker; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * The LaunchDarkly Server-Side AI SDK client. + *

+ * {@code LDAIClient} is the gateway to AI Config functionality. It wraps a fully-configured + * {@link LDClientInterface} (such as an {@code com.launchdarkly.sdk.server.LDClient}) and uses it to + * evaluate AI Config flags, interpolate prompts, and record AI metrics. + *

+ * Construct one instance per application, reusing your existing LaunchDarkly client: + *

{@code
+ * LDClient ldClient = new LDClient(sdkKey);
+ * LDAIClient aiClient = new LDAIClient(ldClient);
+ *
+ * AICompletionConfig config = aiClient.completionConfig(
+ *     "my-ai-config",
+ *     context,
+ *     AICompletionConfigDefault.builder()
+ *         .enabled(true)
+ *         .model(new ModelConfig("gpt-4"))
+ *         .messages(Arrays.asList(LDMessage.system("You are a helpful assistant named {{name}}.")))
+ *         .build(),
+ *     Collections.singletonMap("name", "Bailey"));
+ *
+ * if (config.isEnabled()) {
+ *   AIConfigTracker tracker = config.createTracker();
+ *   String answer = tracker.trackDurationOf(() -> callModel(config));
+ *   tracker.trackSuccess();
+ * }
+ * }
+ */ +public final class LDAIClient { + private static final String TRACK_SDK_INFO = "$ld:ai:sdk:info"; + private static final String TRACK_USAGE_COMPLETION_CONFIG = "$ld:ai:usage:completion-config"; + private static final String TRACK_USAGE_AGENT_CONFIG = "$ld:ai:usage:agent-config"; + private static final String TRACK_USAGE_AGENT_CONFIGS = "$ld:ai:usage:agent-configs"; + private static final String TRACK_USAGE_JUDGE_CONFIG = "$ld:ai:usage:judge-config"; + + private static final LDContext INIT_TRACK_CONTEXT = LDContext.builder("ld-internal-tracking") + .kind("ld_ai") + .anonymous(true) + .build(); + + private final LDClientInterface client; + private final LDLogger logger; + + /** + * Creates an AI client wrapping the given LaunchDarkly client. + *

+ * No assertion is made about the state of the supplied client; it is the caller's responsibility + * to ensure it is properly configured and initialized before relying on AI Config functionality. + *

+ * Construction emits a single {@code $ld:ai:sdk:info} event identifying this AI SDK and version. + * + * @param client a fully-configured LaunchDarkly client + */ + public LDAIClient(LDClientInterface client) { + this.client = client; + this.logger = client.getLogger() != null ? client.getLogger() : LDLogger.none(); + + client.trackMetric( + TRACK_SDK_INFO, + INIT_TRACK_CONTEXT, + LDValue.buildObject() + .put("aiSdkName", AISdkInfo.AI_SDK_NAME) + .put("aiSdkVersion", AISdkInfo.AI_SDK_VERSION) + .put("aiSdkLanguage", AISdkInfo.AI_SDK_LANGUAGE) + .build(), + 1); + } + + /** + * Retrieves a completion ("traditional" chat-style) AI Config. + * + * @param key the configuration key + * @param context the evaluation context + * @param defaultValue the default value used when no flag variation is available; when + * {@code null}, a disabled default is used + * @param variables variables for message interpolation, or {@code null} + * @return the completion config, with a tracker factory for gathering metrics + */ + public AICompletionConfig completionConfig( + String key, + LDContext context, + AICompletionConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1); + + AICompletionConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled(); + Evaluation evaluation = evaluate(key, context, effectiveDefault.toLDValue(), variables, null); + + return AICompletionConfig.builder(key) + .enabled(evaluation.enabled) + .model(evaluation.model) + .provider(evaluation.provider) + .messages(evaluation.messages) + .judgeConfiguration(evaluation.judgeConfiguration) + .tools(resolveTools(evaluation.variation)) + .trackerFactory(evaluation.trackerFactory) + .evaluator(Evaluator.noop()) + .build(); + } + + /** + * Retrieves a single AI Config agent. + * + * @param key the agent configuration key + * @param context the evaluation context + * @param defaultValue the default value used when no flag variation is available; when + * {@code null}, a disabled default is used + * @param variables variables for instruction interpolation, or {@code null} + * @return the agent config, with a tracker factory for gathering metrics + */ + public AIAgentConfig agentConfig( + String key, + LDContext context, + AIAgentConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_AGENT_CONFIG, context, LDValue.of(key), 1); + return evaluateAgent(key, context, defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled(), + variables, null); + } + + /** + * Retrieves multiple AI Config agents in a single call. + * + * @param requests the agent requests, each with its own key, default, and variables + * @param context the evaluation context + * @return a map from agent key to its resolved {@link AIAgentConfig} + */ + public Map agentConfigs(List requests, LDContext context) { + int agentCount = requests.size(); + client.trackMetric(TRACK_USAGE_AGENT_CONFIGS, context, LDValue.of(agentCount), agentCount); + + Map result = new LinkedHashMap<>(); + for (AIAgentConfigRequest request : requests) { + AIAgentConfigDefault requestDefault = + request.getDefaultValue() != null ? request.getDefaultValue() : AIAgentConfigDefault.disabled(); + AIAgentConfig agent = evaluateAgent(request.getKey(), context, requestDefault, request.getVariables(), null); + result.put(request.getKey(), agent); + } + return result; + } + + /** + * Retrieves a judge AI Config used to evaluate AI outputs. + * + * @param key the judge configuration key + * @param context the evaluation context + * @param defaultValue the default value used when no flag variation is available; when + * {@code null}, a disabled default is used + * @param variables variables for message interpolation, or {@code null} + * @return the judge config, with a tracker factory for gathering metrics + */ + public AIJudgeConfig judgeConfig( + String key, + LDContext context, + AIJudgeConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_JUDGE_CONFIG, context, LDValue.of(key), 1); + + AIJudgeConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled(); + Evaluation evaluation = evaluate(key, context, effectiveDefault.toLDValue(), variables, null); + + String evaluationMetricKey = extractEvaluationMetricKey(evaluation.variation); + + return AIJudgeConfig.builder(key) + .enabled(evaluation.enabled) + .model(evaluation.model) + .provider(evaluation.provider) + .messages(evaluation.messages) + .evaluationMetricKey(evaluationMetricKey) + .trackerFactory(evaluation.trackerFactory) + .build(); + } + + /** + * Reconstructs an {@link AIConfigTracker} from a resumption token previously produced by + * {@link AIConfigTracker#getResumptionToken()}. + *

+ * This is the primary mechanism for associating deferred events -- such as user feedback -- with + * the specific config version and invocation that produced the original response. + * + * @param token the resumption token + * @param context the context to use for subsequent track calls + * @return a tracker bound to the original run + * @throws IllegalArgumentException if the token is invalid or missing a required field + */ + public AIConfigTracker createTracker(String token, LDContext context) { + return AIConfigTracker.fromResumptionToken(token, client, context); + } + + private AIAgentConfig evaluateAgent( + String key, + LDContext context, + AIAgentConfigDefault agentDefault, + Map variables, + String graphKey) { + Evaluation evaluation = evaluate(key, context, agentDefault.toLDValue(), variables, graphKey); + + String instructions = evaluation.instructions != null ? evaluation.instructions : agentDefault.getInstructions(); + + return AIAgentConfig.builder(key) + .enabled(evaluation.enabled) + .model(evaluation.model != null ? evaluation.model : agentDefault.getModel()) + .provider(evaluation.provider != null ? evaluation.provider : agentDefault.getProvider()) + .instructions(instructions) + .judgeConfiguration(evaluation.judgeConfiguration) + .tools(resolveTools(evaluation.variation)) + .trackerFactory(evaluation.trackerFactory) + .evaluator(Evaluator.noop()) + .build(); + } + + private Evaluation evaluate( + String key, + LDContext context, + LDValue defaultValue, + Map variables, + String graphKey) { + LDValue variation = client.jsonValueVariation(key, context, defaultValue); + if (variation.getType() != LDValueType.OBJECT) { + logger.error("AI Config '{}' did not return a JSON object; falling back to the provided default.", key); + variation = defaultValue; + } + + Map allVariables = buildVariables(context, variables); + + List messages = parseMessages(variation.get("messages"), allVariables); + String instructions = parseInstructions(variation.get("instructions"), allVariables); + ProviderConfig provider = parseProvider(variation.get("provider")); + ModelConfig model = parseModel(variation.get("model")); + JudgeConfiguration judgeConfiguration = parseJudgeConfiguration(variation.get("judgeConfiguration")); + + LDValue ldMeta = variation.get("_ldMeta"); + String variationKey = ldMeta.get("variationKey").isNull() ? "" : ldMeta.get("variationKey").stringValue(); + int version = ldMeta.get("version").isNull() ? 1 : ldMeta.get("version").intValue(); + boolean enabled = ldMeta.get("enabled").booleanValue(); + + String modelName = model != null ? model.getName() : ""; + String providerName = provider != null ? provider.getName() : ""; + + Supplier trackerFactory = () -> AIConfigTracker.builder(client) + .logger(logger) + .runId(UUID.randomUUID().toString()) + .configKey(key) + .variationKey(variationKey) + .version(version) + .context(context) + .modelName(modelName) + .providerName(providerName) + .graphKey(graphKey) + .build(); + + Evaluation evaluation = new Evaluation(); + evaluation.model = model; + evaluation.provider = provider; + evaluation.messages = messages; + evaluation.instructions = instructions; + evaluation.trackerFactory = trackerFactory; + evaluation.enabled = enabled; + evaluation.judgeConfiguration = judgeConfiguration; + evaluation.variation = variation; + return evaluation; + } + + private Map buildVariables(LDContext context, Map variables) { + Map allVariables = new LinkedHashMap<>(); + if (variables != null) { + for (Map.Entry entry : variables.entrySet()) { + allVariables.put(entry.getKey(), normalizeVariable(entry.getValue())); + } + } + // The ldctx entry is always added last so it overrides any caller-supplied "ldctx" key. + allVariables.put("ldctx", contextToPlainObject(context)); + return allVariables; + } + + private Object normalizeVariable(Object value) { + if (value instanceof LDValue) { + return LDValueConversions.toPlainObject((LDValue) value); + } + return value; + } + + private Object contextToPlainObject(LDContext context) { + return LDValueConversions.toPlainObject(LDValue.parse(JsonSerialization.serialize(context))); + } + + private List parseMessages(LDValue messagesValue, Map variables) { + if (messagesValue.getType() != LDValueType.ARRAY) { + return null; + } + for (LDValue entry : messagesValue.values()) { + if (entry.getType() != LDValueType.OBJECT) { + return null; + } + } + List messages = new ArrayList<>(messagesValue.size()); + for (LDValue entry : messagesValue.values()) { + String role = entry.get("role").stringValue(); + String content = Interpolator.interpolate(entry.get("content").stringValue(), variables); + messages.add(new LDMessage(role, content)); + } + return messages; + } + + private String parseInstructions(LDValue instructionsValue, Map variables) { + if (instructionsValue.getType() != LDValueType.STRING) { + return null; + } + return Interpolator.interpolate(instructionsValue.stringValue(), variables); + } + + private ProviderConfig parseProvider(LDValue providerValue) { + if (providerValue.getType() != LDValueType.OBJECT) { + return null; + } + return new ProviderConfig(providerValue.get("name").isNull() ? "" : providerValue.get("name").stringValue()); + } + + private ModelConfig parseModel(LDValue modelValue) { + if (modelValue.getType() != LDValueType.OBJECT) { + return null; + } + String name = modelValue.get("name").isNull() ? "" : modelValue.get("name").stringValue(); + LDValue parameters = modelValue.get("parameters"); + LDValue custom = modelValue.get("custom"); + return new ModelConfig(name, parameters, custom); + } + + private JudgeConfiguration parseJudgeConfiguration(LDValue judgeConfigValue) { + if (judgeConfigValue.getType() != LDValueType.OBJECT) { + return null; + } + LDValue judgesValue = judgeConfigValue.get("judges"); + if (judgesValue.getType() != LDValueType.ARRAY) { + return null; + } + List judges = new ArrayList<>(); + for (LDValue judge : judgesValue.values()) { + if (judge.getType() == LDValueType.OBJECT + && !judge.get("key").isNull() + && !judge.get("samplingRate").isNull()) { + judges.add(new JudgeConfiguration.Judge( + judge.get("key").stringValue(), + judge.get("samplingRate").doubleValue())); + } + } + return judges.isEmpty() ? null : new JudgeConfiguration(judges); + } + + private String extractEvaluationMetricKey(LDValue variation) { + LDValue metricKey = variation.get("evaluationMetricKey"); + if (!metricKey.isNull()) { + return metricKey.stringValue(); + } + LDValue metricKeys = variation.get("evaluationMetricKeys"); + if (metricKeys.getType() == LDValueType.ARRAY && metricKeys.size() > 0) { + return metricKeys.get(0).stringValue(); + } + return null; + } + + private Map resolveTools(LDValue variation) { + LDValue toolsValue = variation.get("tools"); + if (!toolsValue.isNull()) { + if (toolsValue.getType() != LDValueType.OBJECT) { + return null; + } + Map tools = new LinkedHashMap<>(); + for (String toolName : toolsValue.keys()) { + LDValue toolValue = toolsValue.get(toolName); + if (toolValue.getType() == LDValueType.OBJECT) { + tools.put(toolName, toolFromValue( + toolValue.get("name").isNull() ? toolName : toolValue.get("name").stringValue(), + toolValue)); + } else { + logger.warn("Skipping tool '{}': expected an object", toolName); + } + } + return tools.isEmpty() ? null : tools; + } + + LDValue model = variation.get("model"); + if (model.getType() != LDValueType.OBJECT) { + return null; + } + LDValue parameters = model.get("parameters"); + if (parameters.getType() != LDValueType.OBJECT) { + return null; + } + LDValue toolsList = parameters.get("tools"); + if (toolsList.getType() != LDValueType.ARRAY) { + return null; + } + Map tools = new LinkedHashMap<>(); + for (LDValue item : toolsList.values()) { + if (item.getType() != LDValueType.OBJECT) { + logger.warn("Skipping tool entry: expected an object"); + continue; + } + if (item.get("name").isNull() || item.get("name").stringValue().isEmpty()) { + logger.warn("Skipping tool entry: missing name"); + continue; + } + String toolName = item.get("name").stringValue(); + tools.put(toolName, toolFromValue(toolName, item)); + } + return tools.isEmpty() ? null : tools; + } + + private LDTool toolFromValue(String name, LDValue value) { + LDTool.Builder builder = LDTool.builder(name); + if (!value.get("description").isNull()) { + builder.description(value.get("description").stringValue()); + } + if (!value.get("type").isNull()) { + builder.type(value.get("type").stringValue()); + } + if (!value.get("parameters").isNull()) { + builder.parameters(value.get("parameters")); + } + if (!value.get("customParameters").isNull()) { + builder.customParameters(value.get("customParameters")); + } + return builder.build(); + } + + /** + * Internal carrier for the components extracted from a flag variation. + */ + private static final class Evaluation { + private ModelConfig model; + private ProviderConfig provider; + private List messages; + private String instructions; + private Supplier trackerFactory; + private boolean enabled; + private JudgeConfiguration judgeConfiguration; + private LDValue variation; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfig.java new file mode 100644 index 00000000..4ed60f49 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfig.java @@ -0,0 +1,151 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.server.ai.evaluation.Evaluator; +import com.launchdarkly.sdk.server.ai.tracking.AIConfigTracker; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * An AI Config agent ("agent" mode), composed of freeform instructions rather than chat messages. + *

+ * Instances are produced by {@code LDAIClient.agentConfig} and {@code LDAIClient.agentConfigs}; + * application code does not normally construct them directly. + */ +public final class AIAgentConfig extends AIConfig { + private final String instructions; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + private final Evaluator evaluator; + + private AIAgentConfig(Builder builder) { + super(builder.key, builder.enabled, builder.model, builder.provider, builder.trackerFactory); + this.instructions = builder.instructions; + this.judgeConfiguration = builder.judgeConfiguration; + this.tools = builder.tools == null ? null : Collections.unmodifiableMap(builder.tools); + this.evaluator = builder.evaluator == null ? Evaluator.noop() : builder.evaluator; + } + + /** + * Returns the agent instructions, already interpolated. + * + * @return the instructions, or {@code null} if none were provided + */ + public String getInstructions() { + return instructions; + } + + /** + * Returns the judge configuration attached to this config. + * + * @return the judge configuration, or {@code null} if none was provided + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the root-level tools map. + * + * @return an unmodifiable map of tool name to tool, or {@code null} if no tools were provided + */ + public Map getTools() { + return tools; + } + + /** + * Returns the evaluator built from this config's judge configuration. + * + * @return the evaluator (never {@code null}) + */ + public Evaluator getEvaluator() { + return evaluator; + } + + /** + * Creates a builder for an {@link AIAgentConfig}. + * + * @param key the configuration key + * @return a new builder + */ + public static Builder builder(String key) { + return new Builder(key); + } + + /** + * A builder for {@link AIAgentConfig} instances. + */ + public static final class Builder { + private final String key; + private boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + private Supplier trackerFactory; + private String instructions; + private JudgeConfiguration judgeConfiguration; + private Map tools; + private Evaluator evaluator; + + private Builder(String key) { + this.key = key; + } + + /** @param enabled whether the config is enabled @return this builder */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** @param model the model configuration @return this builder */ + public Builder model(ModelConfig model) { + this.model = model; + return this; + } + + /** @param provider the provider configuration @return this builder */ + public Builder provider(ProviderConfig provider) { + this.provider = provider; + return this; + } + + /** @param trackerFactory the per-invocation tracker factory @return this builder */ + public Builder trackerFactory(Supplier trackerFactory) { + this.trackerFactory = trackerFactory; + return this; + } + + /** @param instructions the interpolated agent instructions @return this builder */ + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + /** @param judgeConfiguration the judge configuration @return this builder */ + public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { + this.judgeConfiguration = judgeConfiguration; + return this; + } + + /** @param tools the root-level tools map @return this builder */ + public Builder tools(Map tools) { + this.tools = tools; + return this; + } + + /** @param evaluator the evaluator built from the judge configuration @return this builder */ + public Builder evaluator(Evaluator evaluator) { + this.evaluator = evaluator; + return this; + } + + /** + * Builds the agent config. + * + * @return a new {@link AIAgentConfig} + */ + public AIAgentConfig build() { + return new AIAgentConfig(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigDefault.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigDefault.java new file mode 100644 index 00000000..90675120 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigDefault.java @@ -0,0 +1,143 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.util.Map; + +/** + * A user-constructed default value for {@code LDAIClient.agentConfig} and + * {@code LDAIClient.agentConfigs}. + */ +public final class AIAgentConfigDefault extends AIConfigDefault { + private final String instructions; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + + private AIAgentConfigDefault(Builder builder) { + super(builder.enabled, builder.model, builder.provider); + this.instructions = builder.instructions; + this.judgeConfiguration = builder.judgeConfiguration; + this.tools = builder.tools; + } + + /** + * Returns a disabled default. + * + * @return a default with {@code enabled} set to {@code false} + */ + public static AIAgentConfigDefault disabled() { + return builder().enabled(false).build(); + } + + /** + * Returns the default agent instructions. + * + * @return the instructions, or {@code null} if none were provided + */ + public String getInstructions() { + return instructions; + } + + /** + * Returns the default judge configuration. + * + * @return the judge configuration, or {@code null} if none was provided + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the default tools map. + * + * @return the tools, or {@code null} if none were provided + */ + public Map getTools() { + return tools; + } + + @Override + public LDValue toLDValue() { + ObjectBuilder builder = baseObject(); + if (instructions != null) { + builder.put("instructions", instructions); + } + if (judgeConfiguration != null) { + builder.put("judgeConfiguration", judgeConfiguration.toLDValue()); + } + if (tools != null) { + builder.put("tools", AICompletionConfigDefault.toolsToLDValue(tools)); + } + return builder.build(); + } + + /** + * Creates a builder for an {@link AIAgentConfigDefault}. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link AIAgentConfigDefault} instances. + */ + public static final class Builder { + private Boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + private String instructions; + private JudgeConfiguration judgeConfiguration; + private Map tools; + + private Builder() { + } + + /** @param enabled whether the config should be considered enabled @return this builder */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** @param model the model configuration @return this builder */ + public Builder model(ModelConfig model) { + this.model = model; + return this; + } + + /** @param provider the provider configuration @return this builder */ + public Builder provider(ProviderConfig provider) { + this.provider = provider; + return this; + } + + /** @param instructions the default agent instructions @return this builder */ + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + /** @param judgeConfiguration the default judge configuration @return this builder */ + public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { + this.judgeConfiguration = judgeConfiguration; + return this; + } + + /** @param tools the default tools map @return this builder */ + public Builder tools(Map tools) { + this.tools = tools; + return this; + } + + /** + * Builds the default. + * + * @return a new {@link AIAgentConfigDefault} + */ + public AIAgentConfigDefault build() { + return new AIAgentConfigDefault(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigRequest.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigRequest.java new file mode 100644 index 00000000..cb845300 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentConfigRequest.java @@ -0,0 +1,66 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * A single request entry passed to {@code LDAIClient.agentConfigs}, combining an agent key with its + * own default configuration and interpolation variables. + */ +public final class AIAgentConfigRequest { + private final String key; + private final AIAgentConfigDefault defaultValue; + private final Map variables; + + /** + * Creates a request for an agent with no default and no variables. + * + * @param key the agent configuration key + */ + public AIAgentConfigRequest(String key) { + this(key, null, null); + } + + /** + * Creates a request for an agent. + * + * @param key the agent configuration key + * @param defaultValue the default value to use when no flag variation is available, or {@code null} + * @param variables the variables for instruction interpolation, or {@code null} + */ + public AIAgentConfigRequest(String key, AIAgentConfigDefault defaultValue, Map variables) { + this.key = key; + this.defaultValue = defaultValue; + this.variables = variables == null + ? null + : Collections.unmodifiableMap(new HashMap<>(variables)); + } + + /** + * Returns the agent configuration key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns the default value for this agent. + * + * @return the default, or {@code null} if none was provided + */ + public AIAgentConfigDefault getDefaultValue() { + return defaultValue; + } + + /** + * Returns the interpolation variables for this agent. + * + * @return an unmodifiable map of variables, or {@code null} if none were provided + */ + public Map getVariables() { + return variables; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentGraphConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentGraphConfig.java new file mode 100644 index 00000000..44fc064f --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIAgentGraphConfig.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Configuration describing an agentic graph flow composed of multiple interconnected + * {@link AIAgentConfig} nodes. + */ +public final class AIAgentGraphConfig { + private final String key; + private final String rootConfigKey; + private final List edges; + private final boolean enabled; + + /** + * Creates an agent graph configuration. + * + * @param key the graph configuration key + * @param rootConfigKey the key of the root agent node + * @param edges the edges defining relationships between agent nodes + * @param enabled whether the graph is enabled + */ + public AIAgentGraphConfig(String key, String rootConfigKey, List edges, boolean enabled) { + this.key = key; + this.rootConfigKey = rootConfigKey; + this.edges = Collections.unmodifiableList(new ArrayList<>(edges)); + this.enabled = enabled; + } + + /** + * Returns the graph configuration key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns the key of the root agent node. + * + * @return the root config key + */ + public String getRootConfigKey() { + return rootConfigKey; + } + + /** + * Returns the edges defining relationships between agent nodes. + * + * @return an unmodifiable list of edges + */ + public List getEdges() { + return edges; + } + + /** + * Returns whether the graph is enabled. + * + * @return {@code true} if enabled + */ + public boolean isEnabled() { + return enabled; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfig.java new file mode 100644 index 00000000..0f0fce73 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfig.java @@ -0,0 +1,154 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.server.ai.evaluation.Evaluator; +import com.launchdarkly.sdk.server.ai.tracking.AIConfigTracker; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A traditional ("completion" mode) AI Config, composed of chat-style messages. + *

+ * Instances are produced by {@code LDAIClient.completionConfig}; application code does not normally + * construct them directly. + */ +public final class AICompletionConfig extends AIConfig { + private final List messages; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + private final Evaluator evaluator; + + private AICompletionConfig(Builder builder) { + super(builder.key, builder.enabled, builder.model, builder.provider, builder.trackerFactory); + this.messages = builder.messages == null ? null : Collections.unmodifiableList(builder.messages); + this.judgeConfiguration = builder.judgeConfiguration; + this.tools = builder.tools == null ? null : Collections.unmodifiableMap(builder.tools); + this.evaluator = builder.evaluator == null ? Evaluator.noop() : builder.evaluator; + } + + /** + * Returns the prompt messages, with their content already interpolated. + * + * @return the messages, or {@code null} if none were provided + */ + public List getMessages() { + return messages; + } + + /** + * Returns the judge configuration attached to this config. + * + * @return the judge configuration, or {@code null} if none was provided + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the root-level tools map. + * + * @return an unmodifiable map of tool name to tool, or {@code null} if no tools were provided + */ + public Map getTools() { + return tools; + } + + /** + * Returns the evaluator built from this config's judge configuration. + *

+ * When no judges are configured, this is a no-op evaluator that produces an empty result. + * + * @return the evaluator (never {@code null}) + */ + public Evaluator getEvaluator() { + return evaluator; + } + + /** + * Creates a builder for an {@link AICompletionConfig}. + * + * @param key the configuration key + * @return a new builder + */ + public static Builder builder(String key) { + return new Builder(key); + } + + /** + * A builder for {@link AICompletionConfig} instances. + */ + public static final class Builder { + private final String key; + private boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + private Supplier trackerFactory; + private List messages; + private JudgeConfiguration judgeConfiguration; + private Map tools; + private Evaluator evaluator; + + private Builder(String key) { + this.key = key; + } + + /** @param enabled whether the config is enabled @return this builder */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** @param model the model configuration @return this builder */ + public Builder model(ModelConfig model) { + this.model = model; + return this; + } + + /** @param provider the provider configuration @return this builder */ + public Builder provider(ProviderConfig provider) { + this.provider = provider; + return this; + } + + /** @param trackerFactory the per-invocation tracker factory @return this builder */ + public Builder trackerFactory(Supplier trackerFactory) { + this.trackerFactory = trackerFactory; + return this; + } + + /** @param messages the interpolated prompt messages @return this builder */ + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + /** @param judgeConfiguration the judge configuration @return this builder */ + public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { + this.judgeConfiguration = judgeConfiguration; + return this; + } + + /** @param tools the root-level tools map @return this builder */ + public Builder tools(Map tools) { + this.tools = tools; + return this; + } + + /** @param evaluator the evaluator built from the judge configuration @return this builder */ + public Builder evaluator(Evaluator evaluator) { + this.evaluator = evaluator; + return this; + } + + /** + * Builds the completion config. + * + * @return a new {@link AICompletionConfig} + */ + public AICompletionConfig build() { + return new AICompletionConfig(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfigDefault.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfigDefault.java new file mode 100644 index 00000000..384eafc9 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AICompletionConfigDefault.java @@ -0,0 +1,162 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.ArrayBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A user-constructed default value for {@code LDAIClient.completionConfig}. + */ +public final class AICompletionConfigDefault extends AIConfigDefault { + private final List messages; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + + private AICompletionConfigDefault(Builder builder) { + super(builder.enabled, builder.model, builder.provider); + this.messages = builder.messages; + this.judgeConfiguration = builder.judgeConfiguration; + this.tools = builder.tools; + } + + /** + * Returns a disabled default. + * + * @return a default with {@code enabled} set to {@code false} + */ + public static AICompletionConfigDefault disabled() { + return builder().enabled(false).build(); + } + + /** + * Returns the default prompt messages. + * + * @return the messages, or {@code null} if none were provided + */ + public List getMessages() { + return messages; + } + + /** + * Returns the default judge configuration. + * + * @return the judge configuration, or {@code null} if none was provided + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the default tools map. + * + * @return the tools, or {@code null} if none were provided + */ + public Map getTools() { + return tools; + } + + @Override + public LDValue toLDValue() { + ObjectBuilder builder = baseObject(); + builder.put("messages", messagesToLDValue(messages)); + if (judgeConfiguration != null) { + builder.put("judgeConfiguration", judgeConfiguration.toLDValue()); + } + if (tools != null) { + builder.put("tools", toolsToLDValue(tools)); + } + return builder.build(); + } + + static LDValue messagesToLDValue(List messages) { + if (messages == null) { + return LDValue.ofNull(); + } + ArrayBuilder array = LDValue.buildArray(); + for (LDMessage message : messages) { + array.add(message.toLDValue()); + } + return array.build(); + } + + static LDValue toolsToLDValue(Map tools) { + ObjectBuilder object = LDValue.buildObject(); + for (Map.Entry entry : tools.entrySet()) { + object.put(entry.getKey(), entry.getValue().toLDValue()); + } + return object.build(); + } + + /** + * Creates a builder for an {@link AICompletionConfigDefault}. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link AICompletionConfigDefault} instances. + */ + public static final class Builder { + private Boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + private List messages; + private JudgeConfiguration judgeConfiguration; + private Map tools; + + private Builder() { + } + + /** @param enabled whether the config should be considered enabled @return this builder */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** @param model the model configuration @return this builder */ + public Builder model(ModelConfig model) { + this.model = model; + return this; + } + + /** @param provider the provider configuration @return this builder */ + public Builder provider(ProviderConfig provider) { + this.provider = provider; + return this; + } + + /** @param messages the default prompt messages @return this builder */ + public Builder messages(List messages) { + this.messages = messages == null ? null : new ArrayList<>(messages); + return this; + } + + /** @param judgeConfiguration the default judge configuration @return this builder */ + public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { + this.judgeConfiguration = judgeConfiguration; + return this; + } + + /** @param tools the default tools map @return this builder */ + public Builder tools(Map tools) { + this.tools = tools; + return this; + } + + /** + * Builds the default. + * + * @return a new {@link AICompletionConfigDefault} + */ + public AICompletionConfigDefault build() { + return new AICompletionConfigDefault(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfig.java new file mode 100644 index 00000000..db2a9cca --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfig.java @@ -0,0 +1,90 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.server.ai.tracking.AIConfigTracker; + +import java.util.function.Supplier; + +/** + * Base type for the AI Config variants returned by {@code LDAIClient}. + *

+ * All AI Config types share a key, an enabled flag, optional model and provider configuration, and + * the ability to mint a fresh {@link AIConfigTracker} for an AI run via {@link #createTracker()}. + */ +public abstract class AIConfig { + private final String key; + private final boolean enabled; + private final ModelConfig model; + private final ProviderConfig provider; + private final Supplier trackerFactory; + + /** + * Constructs a base AI Config. + * + * @param key the configuration key + * @param enabled whether the configuration is enabled + * @param model the model configuration, or {@code null} + * @param provider the provider configuration, or {@code null} + * @param trackerFactory a factory that produces a fresh tracker per invocation + */ + protected AIConfig( + String key, + boolean enabled, + ModelConfig model, + ProviderConfig provider, + Supplier trackerFactory) { + this.key = key; + this.enabled = enabled; + this.model = model; + this.provider = provider; + this.trackerFactory = trackerFactory; + } + + /** + * Returns the configuration key used for tracking and identification. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns whether the configuration is enabled. + * + * @return {@code true} if enabled + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Returns the model configuration. + * + * @return the model configuration, or {@code null} if none was provided + */ + public ModelConfig getModel() { + return model; + } + + /** + * Returns the provider configuration. + * + * @return the provider configuration, or {@code null} if none was provided + */ + public ProviderConfig getProvider() { + return provider; + } + + /** + * Creates a new {@link AIConfigTracker} for a single AI run. + *

+ * Each call mints a tracker with a new {@code runId} (a UUIDv4) so that LaunchDarkly can correlate + * the run's events in metrics views. Call this once per AI run; metrics from different runs cannot + * be combined. + * + * @return a fresh tracker + */ + public AIConfigTracker createTracker() { + return trackerFactory.get(); + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigDefault.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigDefault.java new file mode 100644 index 00000000..11618a83 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigDefault.java @@ -0,0 +1,77 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; + +/** + * Base type for the user-constructed default values passed to the {@code LDAIClient} configuration + * methods. A default is used as the fallback value when no flag variation is available. + */ +public abstract class AIConfigDefault { + private final Boolean enabled; + private final ModelConfig model; + private final ProviderConfig provider; + + /** + * Constructs a base default. + * + * @param enabled whether the config should be considered enabled, or {@code null} to leave unset + * @param model the model configuration, or {@code null} + * @param provider the provider configuration, or {@code null} + */ + protected AIConfigDefault(Boolean enabled, ModelConfig model, ProviderConfig provider) { + this.enabled = enabled; + this.model = model; + this.provider = provider; + } + + /** + * Returns whether the config should be considered enabled. + * + * @return the enabled flag, or {@code null} if unset + */ + public Boolean getEnabled() { + return enabled; + } + + /** + * Returns the model configuration. + * + * @return the model configuration, or {@code null} + */ + public ModelConfig getModel() { + return model; + } + + /** + * Returns the provider configuration. + * + * @return the provider configuration, or {@code null} + */ + public ProviderConfig getProvider() { + return provider; + } + + /** + * Builds an {@link LDValue} object containing the fields common to all default types, suitable for + * use as the default value of a JSON flag evaluation. + * + * @return an object builder seeded with {@code _ldMeta}, {@code model}, and {@code provider} + */ + protected ObjectBuilder baseObject() { + LDValue ldMeta = LDValue.buildObject() + .put("enabled", enabled != null && enabled) + .build(); + return LDValue.buildObject() + .put("_ldMeta", ldMeta) + .put("model", model == null ? LDValue.ofNull() : model.toLDValue()) + .put("provider", provider == null ? LDValue.ofNull() : provider.toLDValue()); + } + + /** + * Renders this default value as an {@link LDValue} object. + * + * @return the JSON representation + */ + public abstract LDValue toLDValue(); +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfig.java new file mode 100644 index 00000000..b9332fe6 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfig.java @@ -0,0 +1,115 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.server.ai.tracking.AIConfigTracker; + +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +/** + * A judge AI Config ("judge" mode), used to evaluate AI outputs. It is composed of chat-style + * evaluation messages and a required evaluation metric key. + *

+ * Instances are produced by {@code LDAIClient.judgeConfig}; application code does not normally + * construct them directly. + */ +public final class AIJudgeConfig extends AIConfig { + private final List messages; + private final String evaluationMetricKey; + + private AIJudgeConfig(Builder builder) { + super(builder.key, builder.enabled, builder.model, builder.provider, builder.trackerFactory); + this.messages = builder.messages == null ? null : Collections.unmodifiableList(builder.messages); + this.evaluationMetricKey = builder.evaluationMetricKey; + } + + /** + * Returns the evaluation prompt messages, with their content already interpolated. + * + * @return the messages, or {@code null} if none were provided + */ + public List getMessages() { + return messages; + } + + /** + * Returns the metric key that this judge evaluates. + * + * @return the evaluation metric key, or {@code null} if none was provided + */ + public String getEvaluationMetricKey() { + return evaluationMetricKey; + } + + /** + * Creates a builder for an {@link AIJudgeConfig}. + * + * @param key the configuration key + * @return a new builder + */ + public static Builder builder(String key) { + return new Builder(key); + } + + /** + * A builder for {@link AIJudgeConfig} instances. + */ + public static final class Builder { + private final String key; + private boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + private Supplier trackerFactory; + private List messages; + private String evaluationMetricKey; + + private Builder(String key) { + this.key = key; + } + + /** @param enabled whether the config is enabled @return this builder */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** @param model the model configuration @return this builder */ + public Builder model(ModelConfig model) { + this.model = model; + return this; + } + + /** @param provider the provider configuration @return this builder */ + public Builder provider(ProviderConfig provider) { + this.provider = provider; + return this; + } + + /** @param trackerFactory the per-invocation tracker factory @return this builder */ + public Builder trackerFactory(Supplier trackerFactory) { + this.trackerFactory = trackerFactory; + return this; + } + + /** @param messages the interpolated evaluation messages @return this builder */ + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + /** @param evaluationMetricKey the metric key this judge evaluates @return this builder */ + public Builder evaluationMetricKey(String evaluationMetricKey) { + this.evaluationMetricKey = evaluationMetricKey; + return this; + } + + /** + * Builds the judge config. + * + * @return a new {@link AIJudgeConfig} + */ + public AIJudgeConfig build() { + return new AIJudgeConfig(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfigDefault.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfigDefault.java new file mode 100644 index 00000000..b1dd6094 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIJudgeConfigDefault.java @@ -0,0 +1,118 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.util.List; + +/** + * A user-constructed default value for {@code LDAIClient.judgeConfig}. + */ +public final class AIJudgeConfigDefault extends AIConfigDefault { + private final List messages; + private final String evaluationMetricKey; + + private AIJudgeConfigDefault(Builder builder) { + super(builder.enabled, builder.model, builder.provider); + this.messages = builder.messages; + this.evaluationMetricKey = builder.evaluationMetricKey; + } + + /** + * Returns a disabled default. + * + * @return a default with {@code enabled} set to {@code false} + */ + public static AIJudgeConfigDefault disabled() { + return builder().enabled(false).build(); + } + + /** + * Returns the default evaluation messages. + * + * @return the messages, or {@code null} if none were provided + */ + public List getMessages() { + return messages; + } + + /** + * Returns the default evaluation metric key. + * + * @return the evaluation metric key, or {@code null} if none was provided + */ + public String getEvaluationMetricKey() { + return evaluationMetricKey; + } + + @Override + public LDValue toLDValue() { + ObjectBuilder builder = baseObject(); + builder.put("messages", AICompletionConfigDefault.messagesToLDValue(messages)); + builder.put("evaluationMetricKey", + evaluationMetricKey == null ? LDValue.ofNull() : LDValue.of(evaluationMetricKey)); + return builder.build(); + } + + /** + * Creates a builder for an {@link AIJudgeConfigDefault}. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link AIJudgeConfigDefault} instances. + */ + public static final class Builder { + private Boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + private List messages; + private String evaluationMetricKey; + + private Builder() { + } + + /** @param enabled whether the config should be considered enabled @return this builder */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** @param model the model configuration @return this builder */ + public Builder model(ModelConfig model) { + this.model = model; + return this; + } + + /** @param provider the provider configuration @return this builder */ + public Builder provider(ProviderConfig provider) { + this.provider = provider; + return this; + } + + /** @param messages the default evaluation messages @return this builder */ + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + /** @param evaluationMetricKey the metric key this judge evaluates @return this builder */ + public Builder evaluationMetricKey(String evaluationMetricKey) { + this.evaluationMetricKey = evaluationMetricKey; + return this; + } + + /** + * Builds the default. + * + * @return a new {@link AIJudgeConfigDefault} + */ + public AIJudgeConfigDefault build() { + return new AIJudgeConfigDefault(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Edge.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Edge.java new file mode 100644 index 00000000..8e5873a4 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/Edge.java @@ -0,0 +1,92 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; + +import java.util.Objects; + +/** + * An edge in an {@link AIAgentGraphConfig}, describing a directed relationship between two agent + * nodes and any handoff options associated with it. + */ +public final class Edge { + private final String key; + private final String sourceConfig; + private final String targetConfig; + private final LDValue handoff; + + /** + * Creates an edge. + * + * @param key the edge key + * @param sourceConfig the key of the source agent node + * @param targetConfig the key of the target agent node + * @param handoff handoff options for this relationship, or {@link LDValue#ofNull()} + */ + public Edge(String key, String sourceConfig, String targetConfig, LDValue handoff) { + this.key = key; + this.sourceConfig = sourceConfig; + this.targetConfig = targetConfig; + this.handoff = handoff == null ? LDValue.ofNull() : handoff; + } + + /** + * Returns the edge key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns the key of the source agent node. + * + * @return the source config key + */ + public String getSourceConfig() { + return sourceConfig; + } + + /** + * Returns the key of the target agent node. + * + * @return the target config key + */ + public String getTargetConfig() { + return targetConfig; + } + + /** + * Returns the handoff options for this relationship. + * + * @return the handoff options, or {@link LDValue#ofNull()} if none + */ + public LDValue getHandoff() { + return handoff; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Edge)) { + return false; + } + Edge other = (Edge) o; + return Objects.equals(key, other.key) + && Objects.equals(sourceConfig, other.sourceConfig) + && Objects.equals(targetConfig, other.targetConfig) + && Objects.equals(handoff, other.handoff); + } + + @Override + public int hashCode() { + return Objects.hash(key, sourceConfig, targetConfig, handoff); + } + + @Override + public String toString() { + return "Edge{key=" + key + ", sourceConfig=" + sourceConfig + ", targetConfig=" + targetConfig + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java new file mode 100644 index 00000000..2ae789c0 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java @@ -0,0 +1,142 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.ArrayBuilder; +import com.launchdarkly.sdk.LDValue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Configuration describing the judges attached to an AI Config for automatic online evaluation. + *

+ * Each entry pairs a judge configuration key with the sampling rate at which that judge should be + * invoked. + */ +public final class JudgeConfiguration { + /** + * Configuration for a single judge attachment. + */ + public static final class Judge { + private final String key; + private final double samplingRate; + + /** + * Creates a judge attachment. + * + * @param key the judge configuration key + * @param samplingRate the sampling rate, between {@code 0.0} and {@code 1.0} + */ + public Judge(String key, double samplingRate) { + this.key = key; + this.samplingRate = samplingRate; + } + + /** + * Returns the judge configuration key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns the sampling rate. + * + * @return the sampling rate + */ + public double getSamplingRate() { + return samplingRate; + } + + /** + * Renders this judge attachment as an {@link LDValue} object. + * + * @return the JSON representation + */ + public LDValue toLDValue() { + return LDValue.buildObject() + .put("key", key) + .put("samplingRate", samplingRate) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Judge)) { + return false; + } + Judge other = (Judge) o; + return Double.compare(samplingRate, other.samplingRate) == 0 && Objects.equals(key, other.key); + } + + @Override + public int hashCode() { + return Objects.hash(key, samplingRate); + } + + @Override + public String toString() { + return "Judge{key=" + key + ", samplingRate=" + samplingRate + "}"; + } + } + + private final List judges; + + /** + * Creates a judge configuration. + * + * @param judges the judges to attach + */ + public JudgeConfiguration(List judges) { + this.judges = Collections.unmodifiableList(new ArrayList<>(judges)); + } + + /** + * Returns the judges in this configuration. + * + * @return an unmodifiable list of judges + */ + public List getJudges() { + return judges; + } + + /** + * Renders this judge configuration as an {@link LDValue} object. + * + * @return the JSON representation + */ + public LDValue toLDValue() { + ArrayBuilder array = LDValue.buildArray(); + for (Judge judge : judges) { + array.add(judge.toLDValue()); + } + return LDValue.buildObject().put("judges", array.build()).build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof JudgeConfiguration)) { + return false; + } + return Objects.equals(judges, ((JudgeConfiguration) o).judges); + } + + @Override + public int hashCode() { + return Objects.hashCode(judges); + } + + @Override + public String toString() { + return "JudgeConfiguration{judges=" + judges + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java new file mode 100644 index 00000000..9d477182 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java @@ -0,0 +1,118 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; + +import java.util.Objects; + +/** + * A single message used to compose a prompt for an AI Config. + *

+ * A message pairs a {@code role} (one of {@code system}, {@code user}, or {@code assistant}) with + * its {@code content}. When a message is delivered as part of an AI Config, its content is + * interpolated using Mustache templating before the message is returned to the caller. + */ +public final class LDMessage { + /** The {@code system} role. */ + public static final String ROLE_SYSTEM = "system"; + /** The {@code user} role. */ + public static final String ROLE_USER = "user"; + /** The {@code assistant} role. */ + public static final String ROLE_ASSISTANT = "assistant"; + + private final String role; + private final String content; + + /** + * Creates a message. + * + * @param role the role of the message, typically one of {@link #ROLE_SYSTEM}, {@link #ROLE_USER}, + * or {@link #ROLE_ASSISTANT} + * @param content the message content + */ + public LDMessage(String role, String content) { + this.role = role; + this.content = content; + } + + /** + * Creates a {@code system} message. + * + * @param content the message content + * @return the message + */ + public static LDMessage system(String content) { + return new LDMessage(ROLE_SYSTEM, content); + } + + /** + * Creates a {@code user} message. + * + * @param content the message content + * @return the message + */ + public static LDMessage user(String content) { + return new LDMessage(ROLE_USER, content); + } + + /** + * Creates an {@code assistant} message. + * + * @param content the message content + * @return the message + */ + public static LDMessage assistant(String content) { + return new LDMessage(ROLE_ASSISTANT, content); + } + + /** + * Returns the role of the message. + * + * @return the role + */ + public String getRole() { + return role; + } + + /** + * Returns the message content. + * + * @return the content + */ + public String getContent() { + return content; + } + + /** + * Renders this message as an {@link LDValue} object. + * + * @return the JSON representation + */ + public LDValue toLDValue() { + return LDValue.buildObject() + .put("role", role) + .put("content", content) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LDMessage)) { + return false; + } + LDMessage other = (LDMessage) o; + return Objects.equals(role, other.role) && Objects.equals(content, other.content); + } + + @Override + public int hashCode() { + return Objects.hash(role, content); + } + + @Override + public String toString() { + return "LDMessage{role=" + role + ", content=" + content + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDTool.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDTool.java new file mode 100644 index 00000000..e0a03291 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDTool.java @@ -0,0 +1,201 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.util.Objects; + +/** + * A single tool entry from the root-level {@code tools} map of an AI Config. + *

+ * This is distinct from {@code model.parameters.tools[]}, which is the raw array passed to LLM + * providers unmodified. The root-level tools map carries additional metadata such as + * {@link #getCustomParameters() customParameters} that should not be forwarded to the provider. + */ +public final class LDTool { + private final String name; + private final String description; + private final String type; + private final LDValue parameters; + private final LDValue customParameters; + + private LDTool(Builder builder) { + this.name = builder.name; + this.description = builder.description; + this.type = builder.type; + this.parameters = builder.parameters == null ? LDValue.ofNull() : builder.parameters; + this.customParameters = builder.customParameters == null ? LDValue.ofNull() : builder.customParameters; + } + + /** + * Returns the tool name (which matches its key in the tools map). + * + * @return the tool name + */ + public String getName() { + return name; + } + + /** + * Returns the human-readable description of what the tool does. + * + * @return the description, or {@code null} if not specified + */ + public String getDescription() { + return description; + } + + /** + * Returns the tool type (for example {@code "function"}). + * + * @return the type, or {@code null} if not specified + */ + public String getType() { + return type; + } + + /** + * Returns the JSON Schema describing the tool's input parameters. + * + * @return the parameters, or {@link LDValue#ofNull()} if not specified + */ + public LDValue getParameters() { + return parameters; + } + + /** + * Returns custom parameters that are not passed to the LLM provider. + * + * @return the custom parameters, or {@link LDValue#ofNull()} if not specified + */ + public LDValue getCustomParameters() { + return customParameters; + } + + /** + * Renders this tool as an {@link LDValue} object using the wire format (with the + * {@code customParameters} camelCase key). + * + * @return the JSON representation + */ + public LDValue toLDValue() { + ObjectBuilder builder = LDValue.buildObject().put("name", name); + if (description != null) { + builder.put("description", description); + } + if (type != null) { + builder.put("type", type); + } + if (!parameters.isNull()) { + builder.put("parameters", parameters); + } + if (!customParameters.isNull()) { + builder.put("customParameters", customParameters); + } + return builder.build(); + } + + /** + * Creates a builder for a tool with the given name. + * + * @param name the tool name + * @return a new builder + */ + public static Builder builder(String name) { + return new Builder(name); + } + + /** + * A builder for {@link LDTool} instances. + */ + public static final class Builder { + private final String name; + private String description; + private String type; + private LDValue parameters; + private LDValue customParameters; + + private Builder(String name) { + this.name = name; + } + + /** + * Sets the tool description. + * + * @param description the description + * @return this builder + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * Sets the tool type. + * + * @param type the type + * @return this builder + */ + public Builder type(String type) { + this.type = type; + return this; + } + + /** + * Sets the JSON Schema describing the tool's parameters. + * + * @param parameters the parameters + * @return this builder + */ + public Builder parameters(LDValue parameters) { + this.parameters = parameters; + return this; + } + + /** + * Sets custom parameters that are not passed to the LLM provider. + * + * @param customParameters the custom parameters + * @return this builder + */ + public Builder customParameters(LDValue customParameters) { + this.customParameters = customParameters; + return this; + } + + /** + * Builds the tool. + * + * @return a new {@link LDTool} + */ + public LDTool build() { + return new LDTool(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LDTool)) { + return false; + } + LDTool other = (LDTool) o; + return Objects.equals(name, other.name) + && Objects.equals(description, other.description) + && Objects.equals(type, other.type) + && Objects.equals(parameters, other.parameters) + && Objects.equals(customParameters, other.customParameters); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, type, parameters, customParameters); + } + + @Override + public String toString() { + return "LDTool{name=" + name + ", type=" + type + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java new file mode 100644 index 00000000..c5c354f2 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java @@ -0,0 +1,198 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; + +import java.util.Objects; + +/** + * Configuration describing the model associated with an AI Config, including the model name and + * any provider-specific parameters or custom data. + */ +public final class ModelConfig { + private final String name; + private final LDValue parameters; + private final LDValue custom; + + /** + * Creates a model configuration with just a name. + * + * @param name the name of the model + */ + public ModelConfig(String name) { + this(name, LDValue.ofNull(), LDValue.ofNull()); + } + + /** + * Creates a model configuration. + * + * @param name the name of the model + * @param parameters model-specific parameters as a JSON object, or {@link LDValue#ofNull()} + * @param custom additional customer-provided data as a JSON object, or {@link LDValue#ofNull()} + */ + public ModelConfig(String name, LDValue parameters, LDValue custom) { + this.name = name; + this.parameters = parameters == null ? LDValue.ofNull() : parameters; + this.custom = custom == null ? LDValue.ofNull() : custom; + } + + /** + * Returns the name of the model. + * + * @return the model name + */ + public String getName() { + return name; + } + + /** + * Retrieves a model parameter by key. + *

+ * Requesting the key {@code "name"} returns the model name. Any other key is looked up in the + * model parameters. + * + * @param key the parameter key + * @return the value, or {@link LDValue#ofNull()} if not present + */ + public LDValue getParameter(String key) { + if ("name".equals(key)) { + return LDValue.of(name); + } + if (parameters.getType() != LDValueType.OBJECT) { + return LDValue.ofNull(); + } + return parameters.get(key); + } + + /** + * Retrieves a custom value by key. + * + * @param key the custom data key + * @return the value, or {@link LDValue#ofNull()} if not present + */ + public LDValue getCustom(String key) { + if (custom.getType() != LDValueType.OBJECT) { + return LDValue.ofNull(); + } + return custom.get(key); + } + + /** + * Returns the full set of model parameters. + * + * @return the parameters object, or {@link LDValue#ofNull()} if none were provided + */ + public LDValue getParameters() { + return parameters; + } + + /** + * Returns the full set of custom data. + * + * @return the custom object, or {@link LDValue#ofNull()} if none was provided + */ + public LDValue getCustom() { + return custom; + } + + /** + * Renders this model config as an {@link LDValue} object. + * + * @return the JSON representation + */ + public LDValue toLDValue() { + return LDValue.buildObject() + .put("name", name) + .put("parameters", parameters) + .put("custom", custom) + .build(); + } + + /** + * Creates a builder for a model configuration. + * + * @param name the name of the model + * @return a new builder + */ + public static Builder builder(String name) { + return new Builder(name); + } + + /** + * A builder for {@link ModelConfig} instances. + */ + public static final class Builder { + private final String name; + private final ObjectBuilder parameters = LDValue.buildObject(); + private final ObjectBuilder custom = LDValue.buildObject(); + private boolean hasParameters = false; + private boolean hasCustom = false; + + private Builder(String name) { + this.name = name; + } + + /** + * Adds a model parameter. + * + * @param key the parameter key + * @param value the parameter value + * @return this builder + */ + public Builder parameter(String key, LDValue value) { + parameters.put(key, value); + hasParameters = true; + return this; + } + + /** + * Adds a custom data entry. + * + * @param key the custom data key + * @param value the custom data value + * @return this builder + */ + public Builder custom(String key, LDValue value) { + custom.put(key, value); + hasCustom = true; + return this; + } + + /** + * Builds the model configuration. + * + * @return a new {@link ModelConfig} + */ + public ModelConfig build() { + return new ModelConfig( + name, + hasParameters ? parameters.build() : LDValue.ofNull(), + hasCustom ? custom.build() : LDValue.ofNull()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ModelConfig)) { + return false; + } + ModelConfig other = (ModelConfig) o; + return Objects.equals(name, other.name) + && Objects.equals(parameters, other.parameters) + && Objects.equals(custom, other.custom); + } + + @Override + public int hashCode() { + return Objects.hash(name, parameters, custom); + } + + @Override + public String toString() { + return "ModelConfig{name=" + name + ", parameters=" + parameters + ", custom=" + custom + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java new file mode 100644 index 00000000..d2827df1 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java @@ -0,0 +1,61 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import com.launchdarkly.sdk.LDValue; + +import java.util.Objects; + +/** + * Configuration describing the AI provider associated with an AI Config (for example + * {@code "openai"}). + */ +public final class ProviderConfig { + private final String name; + + /** + * Creates a provider configuration. + * + * @param name the name of the provider + */ + public ProviderConfig(String name) { + this.name = name; + } + + /** + * Returns the name of the provider. + * + * @return the provider name + */ + public String getName() { + return name; + } + + /** + * Renders this provider config as an {@link LDValue} object. + * + * @return the JSON representation + */ + public LDValue toLDValue() { + return LDValue.buildObject().put("name", name).build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ProviderConfig)) { + return false; + } + return Objects.equals(name, ((ProviderConfig) o).name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } + + @Override + public String toString() { + return "ProviderConfig{name=" + name + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Evaluator.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Evaluator.java new file mode 100644 index 00000000..3fd8ee4e --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Evaluator.java @@ -0,0 +1,63 @@ +package com.launchdarkly.sdk.server.ai.evaluation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Coordinates multiple judge evaluations for a single AI Config invocation. + *

+ * Instances are created by the SDK and attached to {@code AICompletionConfig} and + * {@code AIAgentConfig} results, so callers can run all configured judges with a single call. + * Configurations without judges carry a {@link #noop() no-op} evaluator. + *

+ * The evaluator coordinates evaluations only; it does not perform any LaunchDarkly event tracking. + * Tracking of {@link JudgeResult} values is the responsibility of the caller (typically via + * {@code AIConfigTracker.trackJudgeResult}). + */ +public final class Evaluator { + private final List judges; + + /** + * Creates an evaluator wrapping the given judges. Each judge applies its own sampling rate. + * + * @param judges the initialized judges + */ + public Evaluator(List judges) { + this.judges = Collections.unmodifiableList(new ArrayList<>(judges)); + } + + /** + * Returns a no-op evaluator that resolves immediately to an empty result and invokes no judges. + * + * @return a no-op evaluator + */ + public static Evaluator noop() { + return new Evaluator(Collections.emptyList()); + } + + /** + * Runs all configured judges against the input/output pair. + *

+ * Judges are evaluated in order; the returned future resolves once every judge has completed. + * + * @param input the input that was provided to the AI model + * @param output the AI-generated output to evaluate + * @return a future that resolves to one {@link JudgeResult} per configured judge, in order + */ + public CompletableFuture> evaluate(String input, String output) { + if (judges.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + CompletableFuture> chain = CompletableFuture.completedFuture(new ArrayList<>()); + for (Judge judge : judges) { + chain = chain.thenCompose(accumulated -> + judge.evaluate(input, output).thenApply(result -> { + accumulated.add(result); + return accumulated; + })); + } + return chain.thenApply(Collections::unmodifiableList); + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Judge.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Judge.java new file mode 100644 index 00000000..3c0ec561 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/Judge.java @@ -0,0 +1,33 @@ +package com.launchdarkly.sdk.server.ai.evaluation; + +import com.launchdarkly.sdk.server.ai.datamodel.AIJudgeConfig; + +import java.util.concurrent.CompletableFuture; + +/** + * Evaluates AI outputs against a configured metric. + *

+ * A {@code Judge} pairs an {@link AIJudgeConfig} with a provider-specific model runner that performs + * the actual evaluation. Provider-backed implementations are supplied by AI provider integration + * packages; this interface is the seam through which they plug into the SDK's {@link Evaluator}. + */ +public interface Judge { + /** + * Evaluates the given input/output pair. + *

+ * The judge applies its own sampling rate: when an evaluation is skipped by sampling, the returned + * result has {@link JudgeResult#isSampled()} set to {@code false}. + * + * @param input the input that was provided to the AI model + * @param output the AI-generated output to evaluate + * @return a future that resolves to the evaluation result + */ + CompletableFuture evaluate(String input, String output); + + /** + * Returns the judge configuration retrieved during initialization. + * + * @return the judge configuration + */ + AIJudgeConfig getConfig(); +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/JudgeResult.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/JudgeResult.java new file mode 100644 index 00000000..fe80e0c1 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/evaluation/JudgeResult.java @@ -0,0 +1,175 @@ +package com.launchdarkly.sdk.server.ai.evaluation; + +/** + * The outcome of a single judge metric evaluation. + *

+ * When {@link #isSampled()} is {@code false}, the evaluation was bypassed by the judge's sampling + * rate and the remaining fields are at their default values and should not be treated as results. + */ +public final class JudgeResult { + private final String judgeConfigKey; + private final boolean success; + private final String errorMessage; + private final boolean sampled; + private final String metricKey; + private final Double score; + private final String reasoning; + + private JudgeResult(Builder builder) { + this.judgeConfigKey = builder.judgeConfigKey; + this.success = builder.success; + this.errorMessage = builder.errorMessage; + this.sampled = builder.sampled; + this.metricKey = builder.metricKey; + this.score = builder.score; + this.reasoning = builder.reasoning; + } + + /** + * Returns a result indicating the evaluation was skipped by the sampling rate. + * + * @return a not-sampled result + */ + public static JudgeResult notSampled() { + return builder().sampled(false).success(false).build(); + } + + /** + * Returns the key of the judge configuration that produced this result. + * + * @return the judge config key, or {@code null} if not set + */ + public String getJudgeConfigKey() { + return judgeConfigKey; + } + + /** + * Returns whether the evaluation completed successfully. + * + * @return {@code true} if successful + */ + public boolean isSuccess() { + return success; + } + + /** + * Returns the error message if the evaluation failed. + * + * @return the error message, or {@code null} + */ + public String getErrorMessage() { + return errorMessage; + } + + /** + * Returns whether the evaluation was sampled and executed. + * + * @return {@code true} if the evaluation was performed + */ + public boolean isSampled() { + return sampled; + } + + /** + * Returns the metric key this result corresponds to. + * + * @return the metric key, or {@code null} + */ + public String getMetricKey() { + return metricKey; + } + + /** + * Returns the evaluation score, between {@code 0.0} and {@code 1.0}. + * + * @return the score, or {@code null} if not available + */ + public Double getScore() { + return score; + } + + /** + * Returns the reasoning behind the score. + * + * @return the reasoning, or {@code null} + */ + public String getReasoning() { + return reasoning; + } + + /** + * Creates a builder for a {@link JudgeResult}. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link JudgeResult} instances. + */ + public static final class Builder { + private String judgeConfigKey; + private boolean success = false; + private String errorMessage; + private boolean sampled = false; + private String metricKey; + private Double score; + private String reasoning; + + private Builder() { + } + + /** @param judgeConfigKey the judge config key @return this builder */ + public Builder judgeConfigKey(String judgeConfigKey) { + this.judgeConfigKey = judgeConfigKey; + return this; + } + + /** @param success whether the evaluation succeeded @return this builder */ + public Builder success(boolean success) { + this.success = success; + return this; + } + + /** @param errorMessage the error message @return this builder */ + public Builder errorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + /** @param sampled whether the evaluation was sampled and executed @return this builder */ + public Builder sampled(boolean sampled) { + this.sampled = sampled; + return this; + } + + /** @param metricKey the metric key @return this builder */ + public Builder metricKey(String metricKey) { + this.metricKey = metricKey; + return this; + } + + /** @param score the evaluation score @return this builder */ + public Builder score(Double score) { + this.score = score; + return this; + } + + /** @param reasoning the reasoning behind the score @return this builder */ + public Builder reasoning(String reasoning) { + this.reasoning = reasoning; + return this; + } + + /** + * Builds the result. + * + * @return a new {@link JudgeResult} + */ + public JudgeResult build() { + return new JudgeResult(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java new file mode 100644 index 00000000..4b665218 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java @@ -0,0 +1,24 @@ +package com.launchdarkly.sdk.server.ai.internal; + +/** + * Internal constants identifying this AI SDK package, reported once per {@code LDAIClient} via the + * {@code $ld:ai:sdk:info} event. + *

+ * This class is for internal use only and is not part of the supported public API. + */ +public final class AISdkInfo { + /** The published name of this AI SDK package. */ + public static final String AI_SDK_NAME = "launchdarkly-java-server-sdk-ai"; + + /** The implementation language of this AI SDK. */ + public static final String AI_SDK_LANGUAGE = "java"; + + /** The version of this AI SDK. */ + // This constant is updated automatically by release-please when the project version changes. + // x-release-please-start-version + public static final String AI_SDK_VERSION = "0.1.0"; + // x-release-please-end + + private AISdkInfo() { + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java new file mode 100644 index 00000000..04811e4e --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java @@ -0,0 +1,55 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.samskivert.mustache.Mustache; +import com.samskivert.mustache.Mustache.Compiler; +import com.samskivert.mustache.Mustache.Escaper; + +import java.util.Map; + +/** + * Internal helper that renders Mustache templates for AI Config message and instruction + * interpolation. + *

+ * The renderer is configured to match the behavior of the Python reference SDK (which uses the + * {@code chevron} library): + *

    + *
  • Missing or {@code null} variables render as the empty string rather than raising an error.
  • + *
  • {@code {{ value }}} tags HTML-escape {@code &}, {@code <}, {@code >}, and {@code "} (and + * only those characters), while {@code {{{ value }}}} tags emit the raw value.
  • + *
+ *

+ * This class is for internal use only and is not part of the supported public API. + */ +public final class Interpolator { + /** + * Escapes exactly the characters that the Python {@code chevron} library escapes, so that + * interpolated prompts are byte-for-byte compatible across SDKs. + */ + private static final Escaper CHEVRON_ESCAPER = raw -> + raw.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + + // defaultValue("") makes both null-valued and entirely-missing variables render as the empty + // string (it sets nullValue="" and missingIsNull=true). A trailing nullValue("") must NOT be + // chained here, as that would reset missingIsNull and cause missing variables to throw. + private static final Compiler COMPILER = Mustache.compiler() + .escapeHTML(true) + .withEscaper(CHEVRON_ESCAPER) + .defaultValue(""); + + private Interpolator() { + } + + /** + * Renders a Mustache template against the supplied variables. + * + * @param template the template string + * @param variables the variables available to the template + * @return the rendered string + */ + public static String interpolate(String template, Map variables) { + return COMPILER.compile(template).execute(variables); + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConversions.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConversions.java new file mode 100644 index 00000000..e2b55485 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConversions.java @@ -0,0 +1,66 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.launchdarkly.sdk.LDValue; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Internal helpers for converting {@link LDValue} instances to and from the plain Java objects + * (maps, lists, strings, numbers, booleans) understood by the Mustache renderer. + *

+ * This class is for internal use only and is not part of the supported public API. + */ +public final class LDValueConversions { + private LDValueConversions() { + } + + /** + * Converts an {@link LDValue} into a plain Java object tree. + * + * @param value the value to convert (may be {@code null}) + * @return {@code null}, a {@link Boolean}, a {@link Long} or {@link Double}, a {@link String}, + * a {@link List}, or a {@link Map} depending on the value's JSON type + */ + public static Object toPlainObject(LDValue value) { + if (value == null || value.isNull()) { + return null; + } + switch (value.getType()) { + case BOOLEAN: + return value.booleanValue(); + case NUMBER: + return numberToPlainObject(value); + case STRING: + return value.stringValue(); + case ARRAY: { + List list = new ArrayList<>(value.size()); + for (LDValue element : value.values()) { + list.add(toPlainObject(element)); + } + return list; + } + case OBJECT: { + Map map = new LinkedHashMap<>(); + for (String key : value.keys()) { + map.put(key, toPlainObject(value.get(key))); + } + return map; + } + default: + return null; + } + } + + private static Object numberToPlainObject(LDValue value) { + double doubleValue = value.doubleValue(); + // Render whole numbers without a trailing ".0" so that templates such as "{{count}}" match the + // behavior of the Python reference SDK (which preserves integers). + if (doubleValue == Math.rint(doubleValue) && !Double.isInfinite(doubleValue)) { + return (long) doubleValue; + } + return doubleValue; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokens.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokens.java new file mode 100644 index 00000000..3ac34f5e --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokens.java @@ -0,0 +1,180 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Internal codec for AI Config tracker resumption tokens. + *

+ * A resumption token is the URL-safe Base64 (RFC 4648, no padding) encoding of a canonical JSON + * object whose keys appear in a fixed order: {@code runId}, {@code configKey}, {@code variationKey} + * (omitted when absent), {@code version}, {@code graphKey} (omitted when absent). The JSON is + * serialized with no extraneous whitespace so that tokens are stable across SDKs. + *

+ * This class is for internal use only and is not part of the supported public API. + */ +public final class ResumptionTokens { + private ResumptionTokens() { + } + + /** + * Decoded contents of a resumption token. + */ + public static final class Data { + private final String runId; + private final String configKey; + private final String variationKey; + private final int version; + private final String graphKey; + + public Data(String runId, String configKey, String variationKey, int version, String graphKey) { + this.runId = runId; + this.configKey = configKey; + this.variationKey = variationKey; + this.version = version; + this.graphKey = graphKey; + } + + public String getRunId() { + return runId; + } + + public String getConfigKey() { + return configKey; + } + + public String getVariationKey() { + return variationKey; + } + + public int getVersion() { + return version; + } + + public String getGraphKey() { + return graphKey; + } + } + + /** + * Encodes tracker metadata into a resumption token. + * + * @param runId the tracker's run id (required) + * @param configKey the configuration key (required) + * @param variationKey the variation key, or {@code null}/empty to omit + * @param version the variation version + * @param graphKey the containing graph key, or {@code null}/empty to omit + * @return the URL-safe Base64-encoded token + */ + public static String encode(String runId, String configKey, String variationKey, int version, String graphKey) { + StringBuilder json = new StringBuilder(); + json.append('{'); + appendStringField(json, "runId", runId); + json.append(','); + appendStringField(json, "configKey", configKey); + if (variationKey != null && !variationKey.isEmpty()) { + json.append(','); + appendStringField(json, "variationKey", variationKey); + } + json.append(','); + json.append("\"version\":").append(version); + if (graphKey != null && !graphKey.isEmpty()) { + json.append(','); + appendStringField(json, "graphKey", graphKey); + } + json.append('}'); + + byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Decodes a resumption token. + * + * @param token the token string + * @return the decoded data + * @throws IllegalArgumentException if the token cannot be decoded or is missing a required field + */ + public static Data decode(String token) { + if (token == null) { + throw new IllegalArgumentException("Invalid resumption token: token is null"); + } + + String json; + try { + byte[] decoded = Base64.getUrlDecoder().decode(token); + json = new String(decoded, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid resumption token: " + e.getMessage(), e); + } + + LDValue payload; + try { + payload = LDValue.parse(json); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Invalid resumption token: malformed JSON", e); + } + if (payload.getType() != LDValueType.OBJECT) { + throw new IllegalArgumentException("Invalid resumption token: payload is not an object"); + } + + requireField(payload, "runId"); + requireField(payload, "configKey"); + requireField(payload, "version"); + + String variationKey = payload.get("variationKey").isNull() ? "" : payload.get("variationKey").stringValue(); + String graphKey = payload.get("graphKey").isNull() ? null : payload.get("graphKey").stringValue(); + + return new Data( + payload.get("runId").stringValue(), + payload.get("configKey").stringValue(), + variationKey, + payload.get("version").intValue(), + graphKey); + } + + private static void requireField(LDValue payload, String field) { + if (payload.get(field).isNull()) { + throw new IllegalArgumentException("Invalid resumption token: missing required field '" + field + "'"); + } + } + + private static void appendStringField(StringBuilder json, String key, String value) { + json.append('"').append(key).append("\":"); + appendJsonString(json, value); + } + + private static void appendJsonString(StringBuilder json, String value) { + json.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + json.append("\\\""); + break; + case '\\': + json.append("\\\\"); + break; + case '\n': + json.append("\\n"); + break; + case '\r': + json.append("\\r"); + break; + case '\t': + json.append("\\t"); + break; + default: + if (c < 0x20) { + json.append(String.format("\\u%04x", (int) c)); + } else { + json.append(c); + } + } + } + json.append('"'); + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTracker.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTracker.java new file mode 100644 index 00000000..55c7da1a --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTracker.java @@ -0,0 +1,442 @@ +package com.launchdarkly.sdk.server.ai.tracking; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.ai.evaluation.JudgeResult; +import com.launchdarkly.sdk.server.ai.internal.ResumptionTokens; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; + +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Records metrics for a single AI run. + *

+ * All events emitted by a tracker share a {@code runId} (a UUIDv4) so that LaunchDarkly can correlate + * them in metrics views. Obtain a tracker for a new run by calling {@code createTracker()} on an AI + * Config; obtain one bound to a previous run via {@link #fromResumptionToken(String, LDClientInterface, LDContext)}. + *

+ * Each scalar metric (duration, success/error, feedback, tokens, time-to-first-token) is recorded at + * most once per tracker. Subsequent attempts are ignored and logged. Tool-call and judge-result + * events may be recorded multiple times. + */ +public final class AIConfigTracker { + private static final String DURATION_TOTAL = "$ld:ai:duration:total"; + private static final String TOKENS_TTF = "$ld:ai:tokens:ttf"; + private static final String FEEDBACK_POSITIVE = "$ld:ai:feedback:user:positive"; + private static final String FEEDBACK_NEGATIVE = "$ld:ai:feedback:user:negative"; + private static final String GENERATION_SUCCESS = "$ld:ai:generation:success"; + private static final String GENERATION_ERROR = "$ld:ai:generation:error"; + private static final String TOKENS_TOTAL = "$ld:ai:tokens:total"; + private static final String TOKENS_INPUT = "$ld:ai:tokens:input"; + private static final String TOKENS_OUTPUT = "$ld:ai:tokens:output"; + private static final String TOOL_CALL = "$ld:ai:tool_call"; + + private final LDClientInterface client; + private final LDLogger logger; + private final String runId; + private final String configKey; + private final String variationKey; + private final int version; + private final LDContext context; + private final String modelName; + private final String providerName; + private final String graphKey; + private final MetricSummary summary = new MetricSummary(); + + private AIConfigTracker(Builder builder) { + this.client = builder.client; + this.logger = builder.logger != null ? builder.logger + : (builder.client.getLogger() != null ? builder.client.getLogger() : LDLogger.none()); + this.runId = builder.runId; + this.configKey = builder.configKey; + this.variationKey = builder.variationKey; + this.version = builder.version; + this.context = builder.context; + this.modelName = builder.modelName == null ? "" : builder.modelName; + this.providerName = builder.providerName == null ? "" : builder.providerName; + this.graphKey = builder.graphKey; + // Capture the resumption token immediately so it is available on the summary at construction. + this.summary.setResumptionToken(getResumptionToken()); + } + + /** + * Returns a URL-safe Base64-encoded token that can be used to reconstruct this tracker in a + * different process (for example to record deferred feedback). + *

+ * The token contains the {@code runId}, {@code configKey}, {@code version}, and optionally the + * {@code variationKey} and {@code graphKey}. It does not contain the model or provider + * name. + *

+ * Security note: the token contains the flag variation key and version. If passed + * to an untrusted client (such as a browser) this could expose feature flag targeting details. + * Consider keeping the token server-side and exposing only an opaque reference to it. + * + * @return the resumption token + */ + public String getResumptionToken() { + return ResumptionTokens.encode(runId, configKey, variationKey, version, graphKey); + } + + /** + * Reconstructs a tracker from a resumption token, binding it to the original run's identity. + * + * @param token a resumption token previously produced by {@link #getResumptionToken()} + * @param client the LaunchDarkly client used for subsequent track calls + * @param context the context to use for subsequent track calls + * @return a tracker bound to the original {@code runId} + * @throws IllegalArgumentException if the token is invalid or missing a required field + */ + public static AIConfigTracker fromResumptionToken(String token, LDClientInterface client, LDContext context) { + ResumptionTokens.Data data = ResumptionTokens.decode(token); + return builder(client) + .runId(data.getRunId()) + .configKey(data.getConfigKey()) + .variationKey(data.getVariationKey()) + .version(data.getVersion()) + .context(context) + .graphKey(data.getGraphKey()) + .build(); + } + + /** + * Returns a summary of all metrics tracked so far, including the tracker's resumption token. + * + * @return the metric summary + */ + public MetricSummary getSummary() { + return summary; + } + + /** + * Tracks the duration of an AI run. Recorded at most once per tracker. + * + * @param durationMs the duration in milliseconds + */ + public void trackDuration(long durationMs) { + if (summary.getDurationMs() != null) { + warnAlreadyRecorded("trackDuration", "duration"); + return; + } + summary.setDurationMs(durationMs); + client.trackMetric(DURATION_TOTAL, context, getTrackData(), durationMs); + } + + /** + * Tracks the duration of the supplied operation, then returns its result. The duration is recorded + * even if the operation throws, and the exception is rethrown. + * + * @param operation the operation to time + * @param the operation's result type + * @return the operation's result + */ + public T trackDurationOf(Supplier operation) { + long startNanos = System.nanoTime(); + try { + return operation.get(); + } finally { + trackDuration(elapsedMillis(startNanos)); + } + } + + /** + * Tracks the time to first token for a completion. Recorded at most once per tracker. + * + * @param timeToFirstTokenMs the time to first token in milliseconds + */ + public void trackTimeToFirstToken(long timeToFirstTokenMs) { + if (summary.getTimeToFirstTokenMs() != null) { + warnAlreadyRecorded("trackTimeToFirstToken", "time-to-first-token"); + return; + } + summary.setTimeToFirstTokenMs(timeToFirstTokenMs); + client.trackMetric(TOKENS_TTF, context, getTrackData(), timeToFirstTokenMs); + } + + /** + * Tracks user feedback for an AI run. Recorded at most once per tracker. + * + * @param feedback the feedback kind + */ + public void trackFeedback(FeedbackKind feedback) { + if (summary.getFeedback() != null) { + warnAlreadyRecorded("trackFeedback", "feedback"); + return; + } + summary.setFeedback(feedback); + String eventName = feedback == FeedbackKind.POSITIVE ? FEEDBACK_POSITIVE : FEEDBACK_NEGATIVE; + client.trackMetric(eventName, context, getTrackData(), 1); + } + + /** + * Tracks a successful AI generation. Recorded at most once per tracker; shares state with + * {@link #trackError()}. + */ + public void trackSuccess() { + if (summary.getSuccess() != null) { + warnAlreadyRecorded("trackSuccess", "success/error"); + return; + } + summary.setSuccess(true); + client.trackMetric(GENERATION_SUCCESS, context, getTrackData(), 1); + } + + /** + * Tracks an unsuccessful AI generation. Recorded at most once per tracker; shares state with + * {@link #trackSuccess()}. + */ + public void trackError() { + if (summary.getSuccess() != null) { + warnAlreadyRecorded("trackError", "success/error"); + return; + } + summary.setSuccess(false); + client.trackMetric(GENERATION_ERROR, context, getTrackData(), 1); + } + + /** + * Tracks token usage. Recorded at most once per tracker. Only the positive token counts produce + * events. + * + * @param tokens the token usage + */ + public void trackTokens(TokenUsage tokens) { + if (summary.getTokens() != null) { + warnAlreadyRecorded("trackTokens", "token usage"); + return; + } + summary.setTokens(tokens); + LDValue trackData = getTrackData(); + if (tokens.getTotal() > 0) { + client.trackMetric(TOKENS_TOTAL, context, trackData, tokens.getTotal()); + } + if (tokens.getInput() > 0) { + client.trackMetric(TOKENS_INPUT, context, trackData, tokens.getInput()); + } + if (tokens.getOutput() > 0) { + client.trackMetric(TOKENS_OUTPUT, context, trackData, tokens.getOutput()); + } + } + + /** + * Tracks a single tool invocation. May be called multiple times per tracker. + * + * @param toolKey the identifier of the tool that was invoked + */ + public void trackToolCall(String toolKey) { + summary.addToolCall(toolKey); + LDValue trackData = trackDataBuilder().put("toolKey", toolKey).build(); + client.trackMetric(TOOL_CALL, context, trackData, 1); + } + + /** + * Tracks multiple tool invocations by delegating to {@link #trackToolCall(String)} for each key. + * + * @param toolKeys the identifiers of the tools that were invoked + */ + public void trackToolCalls(Iterable toolKeys) { + for (String toolKey : toolKeys) { + trackToolCall(toolKey); + } + } + + /** + * Tracks a single judge evaluation result using the result's metric key and score. + *

+ * No event is emitted when the result was not sampled or did not succeed. + * + * @param judgeResult the judge result to track + */ + public void trackJudgeResult(JudgeResult judgeResult) { + if (!judgeResult.isSampled()) { + return; + } + if (judgeResult.isSuccess() && judgeResult.getMetricKey() != null) { + ObjectBuilder builder = trackDataBuilder(); + if (judgeResult.getJudgeConfigKey() != null) { + builder.put("judgeConfigKey", judgeResult.getJudgeConfigKey()); + } + double score = judgeResult.getScore() == null ? 0.0 : judgeResult.getScore(); + client.trackMetric(judgeResult.getMetricKey(), context, builder.build(), score); + } + } + + /** + * Runs the supplied operation, extracts metrics from its result, and records duration, + * success/error, token usage, and tool calls automatically. + *

+ * If the operation throws, the duration and an error are recorded and the exception is rethrown. + * If the extracted metrics provide a {@code durationMs}, that value is used instead of the measured + * wall-clock time. + * + * @param metricsExtractor a function that extracts {@link AIMetrics} from the operation result + * (may return {@code null} to record only the duration) + * @param operation the operation to run and track + * @param the operation's result type + * @return the operation's result + */ + public T trackMetricsOf(Function metricsExtractor, Supplier operation) { + long startNanos = System.nanoTime(); + T result; + try { + result = operation.get(); + } catch (RuntimeException e) { + trackDuration(elapsedMillis(startNanos)); + trackError(); + throw e; + } + + long elapsedMs = elapsedMillis(startNanos); + AIMetrics metrics = null; + try { + metrics = metricsExtractor.apply(result); + } catch (RuntimeException e) { + logger.warn("Failed to extract metrics: {}", e.toString()); + } + + if (metrics == null) { + trackDuration(elapsedMs); + return result; + } + + trackDuration(metrics.getDurationMs() != null ? metrics.getDurationMs() : elapsedMs); + if (metrics.isSuccess()) { + trackSuccess(); + } else { + trackError(); + } + if (metrics.getTokens() != null) { + trackTokens(metrics.getTokens()); + } + if (metrics.getToolCalls() != null) { + trackToolCalls(metrics.getToolCalls()); + } + return result; + } + + private LDValue getTrackData() { + return trackDataBuilder().build(); + } + + private ObjectBuilder trackDataBuilder() { + ObjectBuilder builder = LDValue.buildObject() + .put("runId", runId) + .put("configKey", configKey) + .put("version", version) + .put("modelName", modelName) + .put("providerName", providerName); + if (variationKey != null && !variationKey.isEmpty()) { + builder.put("variationKey", variationKey); + } + if (graphKey != null && !graphKey.isEmpty()) { + builder.put("graphKey", graphKey); + } + return builder; + } + + private void warnAlreadyRecorded(String method, String metric) { + logger.warn( + "Skipping {}: {} already recorded on this tracker. Call createTracker on the AI Config for a new run. {}", + method, metric, getTrackData()); + } + + private static long elapsedMillis(long startNanos) { + return (System.nanoTime() - startNanos) / 1_000_000L; + } + + /** + * Creates a builder for an {@link AIConfigTracker}. + * + * @param client the LaunchDarkly client used to emit events + * @return a new builder + */ + public static Builder builder(LDClientInterface client) { + return new Builder(client); + } + + /** + * A builder for {@link AIConfigTracker} instances. Used by the SDK to construct trackers; not + * normally needed by application code. + */ + public static final class Builder { + private final LDClientInterface client; + private LDLogger logger; + private String runId; + private String configKey; + private String variationKey = ""; + private int version = 1; + private LDContext context; + private String modelName = ""; + private String providerName = ""; + private String graphKey; + + private Builder(LDClientInterface client) { + this.client = client; + } + + /** @param logger the logger to use @return this builder */ + public Builder logger(LDLogger logger) { + this.logger = logger; + return this; + } + + /** @param runId the run id (UUIDv4) for this tracker @return this builder */ + public Builder runId(String runId) { + this.runId = runId; + return this; + } + + /** @param configKey the configuration key @return this builder */ + public Builder configKey(String configKey) { + this.configKey = configKey; + return this; + } + + /** @param variationKey the variation key, or empty if none @return this builder */ + public Builder variationKey(String variationKey) { + this.variationKey = variationKey == null ? "" : variationKey; + return this; + } + + /** @param version the variation version @return this builder */ + public Builder version(int version) { + this.version = version; + return this; + } + + /** @param context the evaluation context @return this builder */ + public Builder context(LDContext context) { + this.context = context; + return this; + } + + /** @param modelName the model name @return this builder */ + public Builder modelName(String modelName) { + this.modelName = modelName; + return this; + } + + /** @param providerName the provider name @return this builder */ + public Builder providerName(String providerName) { + this.providerName = providerName; + return this; + } + + /** @param graphKey the containing graph key, or {@code null} @return this builder */ + public Builder graphKey(String graphKey) { + this.graphKey = graphKey; + return this; + } + + /** + * Builds the tracker. + * + * @return a new {@link AIConfigTracker} + */ + public AIConfigTracker build() { + return new AIConfigTracker(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIMetrics.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIMetrics.java new file mode 100644 index 00000000..8ce41b3b --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/AIMetrics.java @@ -0,0 +1,113 @@ +package com.launchdarkly.sdk.server.ai.tracking; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A standardized view of the outcome of an AI operation, used by + * {@link AIConfigTracker#trackMetricsOf(java.util.function.Function, java.util.function.Supplier)} + * to drive automatic tracking. + */ +public final class AIMetrics { + private final boolean success; + private final TokenUsage tokens; + private final Long durationMs; + private final List toolCalls; + + private AIMetrics(Builder builder) { + this.success = builder.success; + this.tokens = builder.tokens; + this.durationMs = builder.durationMs; + this.toolCalls = builder.toolCalls == null ? null : Collections.unmodifiableList(builder.toolCalls); + } + + /** + * Returns whether the AI operation was successful. + * + * @return {@code true} if successful + */ + public boolean isSuccess() { + return success; + } + + /** + * Returns the token usage for the operation. + * + * @return the token usage, or {@code null} if unknown + */ + public TokenUsage getTokens() { + return tokens; + } + + /** + * Returns the measured duration of the operation. + *

+ * When present, this value is used instead of the wall-clock time measured by the tracker. + * + * @return the duration in milliseconds, or {@code null} if unknown + */ + public Long getDurationMs() { + return durationMs; + } + + /** + * Returns the tool calls made during the operation. + * + * @return an unmodifiable list of tool keys, or {@code null} if not reported + */ + public List getToolCalls() { + return toolCalls; + } + + /** + * Creates a builder for an {@link AIMetrics} with the given success status. + * + * @param success whether the operation was successful + * @return a new builder + */ + public static Builder builder(boolean success) { + return new Builder(success); + } + + /** + * A builder for {@link AIMetrics} instances. + */ + public static final class Builder { + private final boolean success; + private TokenUsage tokens; + private Long durationMs; + private List toolCalls; + + private Builder(boolean success) { + this.success = success; + } + + /** @param tokens the token usage @return this builder */ + public Builder tokens(TokenUsage tokens) { + this.tokens = tokens; + return this; + } + + /** @param durationMs the measured duration in milliseconds @return this builder */ + public Builder durationMs(Long durationMs) { + this.durationMs = durationMs; + return this; + } + + /** @param toolCalls the tool keys invoked during the operation @return this builder */ + public Builder toolCalls(List toolCalls) { + this.toolCalls = toolCalls == null ? null : new ArrayList<>(toolCalls); + return this; + } + + /** + * Builds the metrics. + * + * @return a new {@link AIMetrics} + */ + public AIMetrics build() { + return new AIMetrics(this); + } + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/FeedbackKind.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/FeedbackKind.java new file mode 100644 index 00000000..9a6ff002 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/FeedbackKind.java @@ -0,0 +1,26 @@ +package com.launchdarkly.sdk.server.ai.tracking; + +/** + * The kinds of user feedback that can be recorded for an AI run. + */ +public enum FeedbackKind { + /** Positive sentiment. */ + POSITIVE("positive"), + /** Negative sentiment. */ + NEGATIVE("negative"); + + private final String value; + + FeedbackKind(String value) { + this.value = value; + } + + /** + * Returns the wire value of this feedback kind. + * + * @return {@code "positive"} or {@code "negative"} + */ + public String getValue() { + return value; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/MetricSummary.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/MetricSummary.java new file mode 100644 index 00000000..bd989925 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/MetricSummary.java @@ -0,0 +1,117 @@ +package com.launchdarkly.sdk.server.ai.tracking; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A summary of the metrics that have been tracked by a single {@link AIConfigTracker}. + *

+ * Each metric (duration, success/error, feedback, tokens, time-to-first-token) is recorded at most + * once per tracker, so this summary reflects a single set of data for the tracker's run. The + * summary also carries the tracker's {@link #getResumptionToken() resumption token}, captured at + * construction. + */ +public final class MetricSummary { + private Long durationMs; + private Boolean success; + private FeedbackKind feedback; + private TokenUsage tokens; + private Long timeToFirstTokenMs; + private final List toolCalls = new ArrayList<>(); + private String resumptionToken; + + MetricSummary() { + } + + /** + * Returns the tracked duration. + * + * @return the duration in milliseconds, or {@code null} if not tracked + */ + public Long getDurationMs() { + return durationMs; + } + + /** + * Returns the tracked success status. + * + * @return {@code true}/{@code false} if a success or error was tracked, or {@code null} if neither + */ + public Boolean getSuccess() { + return success; + } + + /** + * Returns the tracked user feedback. + * + * @return the feedback kind, or {@code null} if not tracked + */ + public FeedbackKind getFeedback() { + return feedback; + } + + /** + * Returns the tracked token usage. + * + * @return the token usage, or {@code null} if not tracked + */ + public TokenUsage getTokens() { + return tokens; + } + + /** + * Returns the tracked time to first token. + * + * @return the time to first token in milliseconds, or {@code null} if not tracked + */ + public Long getTimeToFirstTokenMs() { + return timeToFirstTokenMs; + } + + /** + * Returns the tool calls tracked during this run. + * + * @return an unmodifiable list of tool keys (never {@code null}) + */ + public List getToolCalls() { + return Collections.unmodifiableList(toolCalls); + } + + /** + * Returns the resumption token for the tracker that produced this summary. + * + * @return the URL-safe Base64-encoded resumption token, or {@code null} if tracker construction failed + */ + public String getResumptionToken() { + return resumptionToken; + } + + void setDurationMs(long durationMs) { + this.durationMs = durationMs; + } + + void setSuccess(boolean success) { + this.success = success; + } + + void setFeedback(FeedbackKind feedback) { + this.feedback = feedback; + } + + void setTokens(TokenUsage tokens) { + this.tokens = tokens; + } + + void setTimeToFirstTokenMs(long timeToFirstTokenMs) { + this.timeToFirstTokenMs = timeToFirstTokenMs; + } + + void addToolCall(String toolKey) { + this.toolCalls.add(toolKey); + } + + void setResumptionToken(String resumptionToken) { + this.resumptionToken = resumptionToken; + } +} diff --git a/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/TokenUsage.java b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/TokenUsage.java new file mode 100644 index 00000000..a8be9479 --- /dev/null +++ b/lib/java-server-sdk-ai/src/main/java/com/launchdarkly/sdk/server/ai/tracking/TokenUsage.java @@ -0,0 +1,74 @@ +package com.launchdarkly.sdk.server.ai.tracking; + +import java.util.Objects; + +/** + * Token usage reported by an AI provider for a single AI run. + */ +public final class TokenUsage { + private final int total; + private final int input; + private final int output; + + /** + * Creates a token usage record. + * + * @param total the total number of tokens used + * @param input the number of tokens in the input (prompt) + * @param output the number of tokens in the output (completion) + */ + public TokenUsage(int total, int input, int output) { + this.total = total; + this.input = input; + this.output = output; + } + + /** + * Returns the total number of tokens used. + * + * @return the total token count + */ + public int getTotal() { + return total; + } + + /** + * Returns the number of tokens in the input. + * + * @return the input token count + */ + public int getInput() { + return input; + } + + /** + * Returns the number of tokens in the output. + * + * @return the output token count + */ + public int getOutput() { + return output; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TokenUsage)) { + return false; + } + TokenUsage other = (TokenUsage) o; + return total == other.total && input == other.input && output == other.output; + } + + @Override + public int hashCode() { + return Objects.hash(total, input, output); + } + + @Override + public String toString() { + return "TokenUsage{total=" + total + ", input=" + input + ", output=" + output + "}"; + } +} diff --git a/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientTest.java b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientTest.java new file mode 100644 index 00000000..c497d993 --- /dev/null +++ b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientTest.java @@ -0,0 +1,377 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.ai.datamodel.AIAgentConfig; +import com.launchdarkly.sdk.server.ai.datamodel.AIAgentConfigDefault; +import com.launchdarkly.sdk.server.ai.datamodel.AIAgentConfigRequest; +import com.launchdarkly.sdk.server.ai.datamodel.AICompletionConfig; +import com.launchdarkly.sdk.server.ai.datamodel.AICompletionConfigDefault; +import com.launchdarkly.sdk.server.ai.datamodel.AIJudgeConfig; +import com.launchdarkly.sdk.server.ai.datamodel.AIJudgeConfigDefault; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.internal.AISdkInfo; +import com.launchdarkly.sdk.server.ai.tracking.AIConfigTracker; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class LDAIClientTest { + private static final LDContext USER = LDContext.create("user-key"); + + private MockLDClient newClient() { + return new MockLDClient(); + } + + @Test + public void sdkInfoIsTrackedOnConstruction() { + MockLDClient client = newClient(); + new LDAIClient(client); + + List events = client.eventsNamed("$ld:ai:sdk:info"); + assertEquals(1, events.size()); + MockLDClient.TrackEvent event = events.get(0); + assertEquals("ld-internal-tracking", event.context.getKey()); + assertEquals("ld_ai", event.context.getKind().toString()); + assertTrue(event.context.isAnonymous()); + assertEquals(AISdkInfo.AI_SDK_NAME, event.data.get("aiSdkName").stringValue()); + assertEquals(AISdkInfo.AI_SDK_VERSION, event.data.get("aiSdkVersion").stringValue()); + assertEquals("java", event.data.get("aiSdkLanguage").stringValue()); + assertEquals(1.0, event.metricValue, 0.0); + } + + @Test + public void completionConfigTracksUsageEvent() { + MockLDClient client = newClient(); + LDAIClient ai = new LDAIClient(client); + + ai.completionConfig("my-config", USER, AICompletionConfigDefault.disabled(), null); + + List events = client.eventsNamed("$ld:ai:usage:completion-config"); + assertEquals(1, events.size()); + assertEquals("my-config", events.get(0).data.stringValue()); + assertEquals(1.0, events.get(0).metricValue, 0.0); + } + + @Test + public void completionConfigInterpolatesMessagesAndExposesModel() { + MockLDClient client = newClient().setFlag("model-config", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"variationKey\":\"abcd\",\"version\":1}," + + "\"model\":{\"name\":\"fakeModel\",\"parameters\":{\"temperature\":0.5,\"maxTokens\":4096}," + + "\"custom\":{\"extra-attribute\":\"value\"}}," + + "\"provider\":{\"name\":\"fakeProvider\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"Hello, {{name}}!\"}]}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig( + "model-config", USER, AICompletionConfigDefault.disabled(), Collections.singletonMap("name", "World")); + + assertTrue(config.isEnabled()); + assertEquals("Hello, World!", config.getMessages().get(0).getContent()); + assertEquals("system", config.getMessages().get(0).getRole()); + assertEquals("fakeModel", config.getModel().getName()); + assertEquals(0.5, config.getModel().getParameter("temperature").doubleValue(), 0.0); + assertEquals(4096, config.getModel().getParameter("maxTokens").intValue()); + assertEquals("value", config.getModel().getCustom("extra-attribute").stringValue()); + assertEquals("fakeProvider", config.getProvider().getName()); + assertNotNull(config.getEvaluator()); + } + + @Test + public void completionConfigUsesDefaultWhenFlagMissing() { + MockLDClient client = newClient(); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfigDefault def = AICompletionConfigDefault.builder() + .enabled(true) + .model(new ModelConfig("fallback-model")) + .messages(Collections.singletonList(LDMessage.system("Hello, {{name}}!"))) + .build(); + + AICompletionConfig config = ai.completionConfig("missing-flag", USER, def, + Collections.singletonMap("name", "World")); + + assertTrue(config.isEnabled()); + assertEquals("Hello, World!", config.getMessages().get(0).getContent()); + assertEquals("fallback-model", config.getModel().getName()); + } + + @Test + public void completionConfigWithoutDefaultIsDisabled() { + LDAIClient ai = new LDAIClient(newClient()); + AICompletionConfig config = ai.completionConfig("missing-flag", USER, null, null); + assertFalse(config.isEnabled()); + } + + @Test + public void completionConfigFallsBackToDefaultOnNonObjectVariation() { + MockLDClient client = newClient().setFlag("bad-config", LDValue.of("not an object")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfigDefault def = AICompletionConfigDefault.builder() + .enabled(true) + .messages(Collections.singletonList(LDMessage.system("Hi {{name}}"))) + .build(); + + AICompletionConfig config = ai.completionConfig("bad-config", USER, def, + Collections.singletonMap("name", "Bailey")); + + assertTrue(config.isEnabled()); + assertEquals("Hi Bailey", config.getMessages().get(0).getContent()); + } + + @Test + public void disabledFlagYieldsDisabledConfig() { + MockLDClient client = newClient().setFlag("off-config", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":false,\"version\":1},\"model\":{\"name\":\"m\"},\"messages\":[]}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("off-config", USER, + AICompletionConfigDefault.disabled(), null); + + assertFalse(config.isEnabled()); + assertEquals("m", config.getModel().getName()); + } + + @Test + public void interpolatesSingleContextAttributes() { + MockLDClient client = newClient().setFlag("ctx", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"m\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":" + + "\"Hello, {{ldctx.name}}! Is your last name {{ldctx.last}}?\"}]}")); + LDAIClient ai = new LDAIClient(client); + + LDContext context = LDContext.builder("user-key").name("Sandy").set("last", "Beaches").build(); + AICompletionConfig config = ai.completionConfig("ctx", context, AICompletionConfigDefault.disabled(), null); + + assertEquals("Hello, Sandy! Is your last name Beaches?", config.getMessages().get(0).getContent()); + } + + @Test + public void interpolatesMultiContextAttributes() { + MockLDClient client = newClient().setFlag("multi-ctx", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"m\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":" + + "\"Hello, {{ldctx.user.name}}! Do you work for {{ldctx.org.shortname}}?\"}]}")); + LDAIClient ai = new LDAIClient(client); + + LDContext user = LDContext.builder("user-key").name("Sandy").build(); + LDContext org = LDContext.builder("org-key").kind("org").name("LaunchDarkly").set("shortname", "LD").build(); + LDContext multi = LDContext.createMulti(user, org); + + AICompletionConfig config = ai.completionConfig("multi-ctx", multi, AICompletionConfigDefault.disabled(), null); + assertEquals("Hello, Sandy! Do you work for LD?", config.getMessages().get(0).getContent()); + } + + @Test + public void resolvesRootLevelTools() { + MockLDClient client = newClient().setFlag("tools-config", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"m\"},\"messages\":[]," + + "\"tools\":{\"web-search-tool\":{\"name\":\"web-search-tool\",\"type\":\"function\"," + + "\"parameters\":{\"type\":\"object\"},\"customParameters\":{\"x\":\"y\"}}}}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("tools-config", USER, + AICompletionConfigDefault.disabled(), null); + + assertEquals("function", config.getTools().get("web-search-tool").getType()); + assertEquals("y", config.getTools().get("web-search-tool").getCustomParameters().get("x").stringValue()); + } + + @Test + public void resolvesToolsFromModelParametersWhenNoRootTools() { + MockLDClient client = newClient().setFlag("param-tools", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"messages\":[]," + + "\"model\":{\"name\":\"m\",\"parameters\":{\"tools\":[{\"name\":\"t1\",\"type\":\"function\"}]}}}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("param-tools", USER, + AICompletionConfigDefault.disabled(), null); + + assertEquals("function", config.getTools().get("t1").getType()); + } + + @Test + public void parsesJudgeConfiguration() { + MockLDClient client = newClient().setFlag("judged-config", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"m\"},\"messages\":[]," + + "\"judgeConfiguration\":{\"judges\":[{\"key\":\"relevance-judge\",\"samplingRate\":0.1}]}}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("judged-config", USER, + AICompletionConfigDefault.disabled(), null); + + assertEquals(1, config.getJudgeConfiguration().getJudges().size()); + assertEquals("relevance-judge", config.getJudgeConfiguration().getJudges().get(0).getKey()); + assertEquals(0.1, config.getJudgeConfiguration().getJudges().get(0).getSamplingRate(), 0.0); + } + + @Test + public void createTrackerProducesFreshRunIdEachCall() { + MockLDClient client = newClient().setFlag("model-config", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"variationKey\":\"v1\",\"version\":1},\"model\":{\"name\":\"m\"},\"messages\":[]}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("model-config", USER, + AICompletionConfigDefault.disabled(), null); + AIConfigTracker t1 = config.createTracker(); + AIConfigTracker t2 = config.createTracker(); + + t1.trackSuccess(); + t2.trackSuccess(); + List successes = client.eventsNamed("$ld:ai:generation:success"); + assertEquals(2, successes.size()); + assertFalse(successes.get(0).data.get("runId").stringValue() + .equals(successes.get(1).data.get("runId").stringValue())); + } + + @Test + public void createTrackerFromTokenPreservesRun() { + MockLDClient client = newClient().setFlag("model-config", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"variationKey\":\"v1\",\"version\":3},\"model\":{\"name\":\"gpt-4\"},\"messages\":[]}")); + LDAIClient ai = new LDAIClient(client); + + AIConfigTracker original = ai.completionConfig("model-config", USER, + AICompletionConfigDefault.disabled(), null).createTracker(); + String token = original.getResumptionToken(); + + AIConfigTracker restored = ai.createTracker(token, USER); + restored.trackSuccess(); + + MockLDClient.TrackEvent event = client.eventsNamed("$ld:ai:generation:success").get(0); + assertEquals("model-config", event.data.get("configKey").stringValue()); + assertEquals(3, event.data.get("version").intValue()); + assertEquals("v1", event.data.get("variationKey").stringValue()); + } + + @Test + public void agentConfigInterpolatesInstructionsAndTracksUsage() { + MockLDClient client = newClient().setFlag("research_agent", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"variationKey\":\"abcd\",\"version\":1}," + + "\"model\":{\"name\":\"agent-model\"},\"provider\":{\"name\":\"openai\"}," + + "\"instructions\":\"You are a research assistant specializing in {{topic}}.\"}")); + LDAIClient ai = new LDAIClient(client); + + AIAgentConfig config = ai.agentConfig("research_agent", USER, AIAgentConfigDefault.disabled(), + Collections.singletonMap("topic", "climate change")); + + assertTrue(config.isEnabled()); + assertEquals("You are a research assistant specializing in climate change.", config.getInstructions()); + assertEquals("agent-model", config.getModel().getName()); + assertEquals("openai", config.getProvider().getName()); + assertEquals(1, client.eventsNamed("$ld:ai:usage:agent-config").size()); + } + + @Test + public void agentConfigFallsBackToDefaultModelAndProvider() { + MockLDClient client = newClient().setFlag("agent-no-model", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"instructions\":\"hello\"}")); + LDAIClient ai = new LDAIClient(client); + + AIAgentConfigDefault def = AIAgentConfigDefault.builder() + .enabled(true) + .model(new ModelConfig("default-model")) + .provider(new ProviderConfig("default-provider")) + .build(); + + AIAgentConfig config = ai.agentConfig("agent-no-model", USER, def, null); + + assertEquals("default-model", config.getModel().getName()); + assertEquals("default-provider", config.getProvider().getName()); + assertEquals("hello", config.getInstructions()); + } + + @Test + public void agentConfigsReturnsMapAndTracksCount() { + MockLDClient client = newClient() + .setFlag("research_agent", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"instructions\":\"Research {{topic}}.\"}")) + .setFlag("writing_agent", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"instructions\":\"Write in {{style}} style.\"}")); + LDAIClient ai = new LDAIClient(client); + + Map researchVars = new HashMap<>(); + researchVars.put("topic", "climate change"); + Map writingVars = new HashMap<>(); + writingVars.put("style", "academic"); + + List requests = Arrays.asList( + new AIAgentConfigRequest("research_agent", AIAgentConfigDefault.disabled(), researchVars), + new AIAgentConfigRequest("writing_agent", AIAgentConfigDefault.disabled(), writingVars)); + + Map agents = ai.agentConfigs(requests, USER); + + assertEquals("Research climate change.", agents.get("research_agent").getInstructions()); + assertEquals("Write in academic style.", agents.get("writing_agent").getInstructions()); + + MockLDClient.TrackEvent event = client.eventsNamed("$ld:ai:usage:agent-configs").get(0); + assertEquals(2, event.data.intValue()); + assertEquals(2.0, event.metricValue, 0.0); + } + + @Test + public void judgeConfigExtractsMetricKeyAndTracksUsage() { + MockLDClient client = newClient().setFlag("relevance-judge", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"gpt-4\"}," + + "\"provider\":{\"name\":\"openai\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"Evaluate for {{metric}}.\"}]," + + "\"evaluationMetricKey\":\"$ld:ai:judge:relevance\"}")); + LDAIClient ai = new LDAIClient(client); + + AIJudgeConfig config = ai.judgeConfig("relevance-judge", USER, AIJudgeConfigDefault.disabled(), + Collections.singletonMap("metric", "relevance")); + + assertTrue(config.isEnabled()); + assertEquals("$ld:ai:judge:relevance", config.getEvaluationMetricKey()); + assertEquals("Evaluate for relevance.", config.getMessages().get(0).getContent()); + assertEquals(1, client.eventsNamed("$ld:ai:usage:judge-config").size()); + } + + @Test + public void variationVersionDefaultsToOneWhenMissing() { + MockLDClient client = newClient().setFlag("no-version", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true},\"model\":{\"name\":\"m\"},\"messages\":[]}")); + LDAIClient ai = new LDAIClient(client); + + AIConfigTracker tracker = ai.completionConfig("no-version", USER, + AICompletionConfigDefault.disabled(), null).createTracker(); + tracker.trackSuccess(); + + assertEquals(1, client.eventsNamed("$ld:ai:generation:success").get(0).data.get("version").intValue()); + } + + @Test + public void emptyMessagesListProducesEmptyMessages() { + MockLDClient client = newClient().setFlag("empty-messages", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"m\"},\"messages\":[]}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("empty-messages", USER, + AICompletionConfigDefault.disabled(), null); + assertNotNull(config.getMessages()); + assertTrue(config.getMessages().isEmpty()); + } + + @Test + public void missingMessagesYieldsNull() { + MockLDClient client = newClient().setFlag("no-messages", LDValue.parse( + "{\"_ldMeta\":{\"enabled\":true,\"version\":1},\"model\":{\"name\":\"m\"}}")); + LDAIClient ai = new LDAIClient(client); + + AICompletionConfig config = ai.completionConfig("no-messages", USER, + AICompletionConfigDefault.disabled(), null); + assertNull(config.getMessages()); + } +} diff --git a/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/MockLDClient.java b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/MockLDClient.java new file mode 100644 index 00000000..8422a2c6 --- /dev/null +++ b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/MockLDClient.java @@ -0,0 +1,204 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.FlagsStateOption; +import com.launchdarkly.sdk.server.MigrationOpTracker; +import com.launchdarkly.sdk.server.MigrationStage; +import com.launchdarkly.sdk.server.MigrationVariation; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagTracker; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A test double for {@link LDClientInterface} that returns programmed JSON flag variations and + * records all {@code track} calls. Methods not exercised by the AI SDK return harmless defaults. + */ +public final class MockLDClient implements LDClientInterface { + /** A single recorded track call. */ + public static final class TrackEvent { + public final String eventName; + public final LDContext context; + public final LDValue data; + public final Double metricValue; + + TrackEvent(String eventName, LDContext context, LDValue data, Double metricValue) { + this.eventName = eventName; + this.context = context; + this.data = data; + this.metricValue = metricValue; + } + } + + private final Map flags = new HashMap<>(); + public final List events = new ArrayList<>(); + + /** Programs a JSON variation for a flag key. */ + public MockLDClient setFlag(String key, LDValue value) { + flags.put(key, value); + return this; + } + + public List eventsNamed(String eventName) { + List matching = new ArrayList<>(); + for (TrackEvent event : events) { + if (event.eventName.equals(eventName)) { + matching.add(event); + } + } + return matching; + } + + @Override + public LDValue jsonValueVariation(String key, LDContext context, LDValue defaultValue) { + return flags.containsKey(key) ? flags.get(key) : defaultValue; + } + + @Override + public void track(String eventName, LDContext context) { + events.add(new TrackEvent(eventName, context, LDValue.ofNull(), null)); + } + + @Override + public void trackData(String eventName, LDContext context, LDValue data) { + events.add(new TrackEvent(eventName, context, data, null)); + } + + @Override + public void trackMetric(String eventName, LDContext context, LDValue data, double metricValue) { + events.add(new TrackEvent(eventName, context, data, metricValue)); + } + + @Override + public LDLogger getLogger() { + return LDLogger.none(); + } + + // --- Methods below are not exercised by the AI SDK; they return harmless defaults. --- + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public void trackMigration(MigrationOpTracker tracker) { + } + + @Override + public void identify(LDContext context) { + } + + @Override + public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... options) { + return null; + } + + @Override + public boolean boolVariation(String key, LDContext context, boolean defaultValue) { + return defaultValue; + } + + @Override + public int intVariation(String key, LDContext context, int defaultValue) { + return defaultValue; + } + + @Override + public double doubleVariation(String key, LDContext context, double defaultValue) { + return defaultValue; + } + + @Override + public String stringVariation(String key, LDContext context, String defaultValue) { + return defaultValue; + } + + @Override + public EvaluationDetail boolVariationDetail(String key, LDContext context, boolean defaultValue) { + return EvaluationDetail.fromValue(defaultValue, 0, null); + } + + @Override + public EvaluationDetail intVariationDetail(String key, LDContext context, int defaultValue) { + return EvaluationDetail.fromValue(defaultValue, 0, null); + } + + @Override + public EvaluationDetail doubleVariationDetail(String key, LDContext context, double defaultValue) { + return EvaluationDetail.fromValue(defaultValue, 0, null); + } + + @Override + public EvaluationDetail stringVariationDetail(String key, LDContext context, String defaultValue) { + return EvaluationDetail.fromValue(defaultValue, 0, null); + } + + @Override + public EvaluationDetail jsonValueVariationDetail(String key, LDContext context, LDValue defaultValue) { + return EvaluationDetail.fromValue(jsonValueVariation(key, context, defaultValue), 0, null); + } + + @Override + public MigrationVariation migrationVariation(String key, LDContext context, MigrationStage defaultStage) { + return null; + } + + @Override + public boolean isFlagKnown(String featureKey) { + return flags.containsKey(featureKey); + } + + @Override + public void close() { + } + + @Override + public void flush() { + } + + @Override + public boolean isOffline() { + return false; + } + + @Override + public FlagTracker getFlagTracker() { + return null; + } + + @Override + public BigSegmentStoreStatusProvider getBigSegmentStoreStatusProvider() { + return null; + } + + @Override + public DataSourceStatusProvider getDataSourceStatusProvider() { + return null; + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return null; + } + + @Override + public String secureModeHash(LDContext context) { + return null; + } + + @Override + public String version() { + return "test"; + } +} diff --git a/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/evaluation/EvaluatorTest.java b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/evaluation/EvaluatorTest.java new file mode 100644 index 00000000..14be3ff5 --- /dev/null +++ b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/evaluation/EvaluatorTest.java @@ -0,0 +1,62 @@ +package com.launchdarkly.sdk.server.ai.evaluation; + +import com.launchdarkly.sdk.server.ai.datamodel.AIJudgeConfig; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class EvaluatorTest { + /** A judge that records the order in which it was invoked and returns a fixed result. */ + private static final class FakeJudge implements Judge { + private final JudgeResult result; + private final List invocationLog; + private final String name; + + FakeJudge(String name, JudgeResult result, List invocationLog) { + this.name = name; + this.result = result; + this.invocationLog = invocationLog; + } + + @Override + public CompletableFuture evaluate(String input, String output) { + invocationLog.add(name); + return CompletableFuture.completedFuture(result); + } + + @Override + public AIJudgeConfig getConfig() { + return null; + } + } + + @Test + public void noopEvaluatorResolvesToEmptyList() throws Exception { + List results = Evaluator.noop().evaluate("in", "out").get(); + assertTrue(results.isEmpty()); + } + + @Test + public void runsEachJudgeInOrder() throws ExecutionException, InterruptedException { + List log = new ArrayList<>(); + JudgeResult a = JudgeResult.builder().sampled(true).success(true).metricKey("a").score(0.1).build(); + JudgeResult b = JudgeResult.builder().sampled(true).success(true).metricKey("b").score(0.2).build(); + Evaluator evaluator = new Evaluator(Arrays.asList( + new FakeJudge("first", a, log), + new FakeJudge("second", b, log))); + + List results = evaluator.evaluate("in", "out").get(); + + assertEquals(Arrays.asList("first", "second"), log); + assertEquals(2, results.size()); + assertEquals("a", results.get(0).getMetricKey()); + assertEquals("b", results.get(1).getMetricKey()); + } +} diff --git a/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java new file mode 100644 index 00000000..ddcb8453 --- /dev/null +++ b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java @@ -0,0 +1,56 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class InterpolatorTest { + @Test + public void interpolatesSimpleVariable() { + Map variables = Collections.singletonMap("name", "World"); + assertEquals("Hello, World!", Interpolator.interpolate("Hello, {{name}}!", variables)); + } + + @Test + public void missingVariableRendersEmpty() { + assertEquals("Hello, !", Interpolator.interpolate("Hello, {{name}}!", new HashMap<>())); + } + + @Test + public void nullVariableRendersEmpty() { + Map variables = new HashMap<>(); + variables.put("name", null); + assertEquals("Hello, !", Interpolator.interpolate("Hello, {{name}}!", variables)); + } + + @Test + public void escapesOnlyChevronCharacters() { + Map variables = Collections.singletonMap("x", "&\"hi\""); + assertEquals("a <b>&"hi"</b> b", + Interpolator.interpolate("a {{x}} b", variables)); + } + + @Test + public void tripleBracesDoNotEscape() { + Map variables = Collections.singletonMap("x", "&\"hi\""); + assertEquals("a &\"hi\" b", Interpolator.interpolate("a {{{x}}} b", variables)); + } + + @Test + public void slashIsNotEscaped() { + Map variables = Collections.singletonMap("x", "mind/type"); + assertEquals("mind/type", Interpolator.interpolate("{{x}}", variables)); + } + + @Test + public void interpolatesNestedContext() { + Map ldctx = new HashMap<>(); + ldctx.put("name", "Sandy"); + Map variables = Collections.singletonMap("ldctx", ldctx); + assertEquals("Hi Sandy", Interpolator.interpolate("Hi {{ldctx.name}}", variables)); + } +} diff --git a/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokensTest.java b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokensTest.java new file mode 100644 index 00000000..d8ee99cf --- /dev/null +++ b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/ResumptionTokensTest.java @@ -0,0 +1,77 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +public class ResumptionTokensTest { + private static String decodeJson(String token) { + return new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8); + } + + @Test + public void encodesCanonicalJsonInFixedOrder() { + String token = ResumptionTokens.encode("run-1", "my-config", "abc123", 3, null); + assertEquals("{\"runId\":\"run-1\",\"configKey\":\"my-config\",\"variationKey\":\"abc123\",\"version\":3}", + decodeJson(token)); + } + + @Test + public void omitsEmptyVariationKeyAndGraphKey() { + String token = ResumptionTokens.encode("run-1", "my-config", "", 7, null); + assertEquals("{\"runId\":\"run-1\",\"configKey\":\"my-config\",\"version\":7}", decodeJson(token)); + } + + @Test + public void includesGraphKeyWhenPresent() { + String token = ResumptionTokens.encode("run-1", "my-config", "v1", 2, "graph-1"); + assertEquals( + "{\"runId\":\"run-1\",\"configKey\":\"my-config\",\"variationKey\":\"v1\",\"version\":2,\"graphKey\":\"graph-1\"}", + decodeJson(token)); + } + + @Test + public void roundTripsAllFields() { + String token = ResumptionTokens.encode("run-1", "my-config", "v1", 2, "graph-1"); + ResumptionTokens.Data data = ResumptionTokens.decode(token); + assertEquals("run-1", data.getRunId()); + assertEquals("my-config", data.getConfigKey()); + assertEquals("v1", data.getVariationKey()); + assertEquals(2, data.getVersion()); + assertEquals("graph-1", data.getGraphKey()); + } + + @Test + public void decodeMissingVariationKeyYieldsEmptyAndNullGraph() { + ResumptionTokens.Data data = ResumptionTokens.decode(ResumptionTokens.encode("r", "c", "", 1, null)); + assertEquals("", data.getVariationKey()); + assertNull(data.getGraphKey()); + } + + @Test + public void decodeRejectsMalformedToken() { + try { + ResumptionTokens.decode("!!!not-base64!!!"); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } + } + + @Test + public void decodeRejectsMissingRequiredField() { + String token = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"configKey\":\"c\",\"version\":1}".getBytes(StandardCharsets.UTF_8)); + try { + ResumptionTokens.decode(token); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertEquals("Invalid resumption token: missing required field 'runId'", expected.getMessage()); + } + } +} diff --git a/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTrackerTest.java b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTrackerTest.java new file mode 100644 index 00000000..e45b5733 --- /dev/null +++ b/lib/java-server-sdk-ai/src/test/java/com/launchdarkly/sdk/server/ai/tracking/AIConfigTrackerTest.java @@ -0,0 +1,229 @@ +package com.launchdarkly.sdk.server.ai.tracking; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.ai.MockLDClient; +import com.launchdarkly.sdk.server.ai.evaluation.JudgeResult; +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class AIConfigTrackerTest { + private MockLDClient client; + private LDContext context; + private AIConfigTracker tracker; + + @Before + public void setUp() { + client = new MockLDClient(); + context = LDContext.create("user-key"); + tracker = AIConfigTracker.builder(client) + .runId("run-1") + .configKey("my-config") + .variationKey("var-1") + .version(7) + .context(context) + .modelName("gpt-4") + .providerName("openai") + .build(); + } + + private MockLDClient.TrackEvent single(String eventName) { + assertEquals(1, client.eventsNamed(eventName).size()); + return client.eventsNamed(eventName).get(0); + } + + @Test + public void trackDurationEmitsEventWithMetadata() { + tracker.trackDuration(1234); + MockLDClient.TrackEvent event = single("$ld:ai:duration:total"); + assertEquals(1234.0, event.metricValue, 0.0); + assertEquals("run-1", event.data.get("runId").stringValue()); + assertEquals("my-config", event.data.get("configKey").stringValue()); + assertEquals(7, event.data.get("version").intValue()); + assertEquals("gpt-4", event.data.get("modelName").stringValue()); + assertEquals("openai", event.data.get("providerName").stringValue()); + assertEquals("var-1", event.data.get("variationKey").stringValue()); + assertEquals(Long.valueOf(1234), tracker.getSummary().getDurationMs()); + } + + @Test + public void durationIsRecordedAtMostOnce() { + tracker.trackDuration(100); + tracker.trackDuration(200); + assertEquals(1, client.eventsNamed("$ld:ai:duration:total").size()); + assertEquals(Long.valueOf(100), tracker.getSummary().getDurationMs()); + } + + @Test + public void trackDurationOfReturnsValueAndRecordsDuration() { + String result = tracker.trackDurationOf(() -> "answer"); + assertEquals("answer", result); + assertEquals(1, client.eventsNamed("$ld:ai:duration:total").size()); + } + + @Test + public void trackDurationOfRecordsDurationEvenOnException() { + try { + tracker.trackDurationOf(() -> { + throw new RuntimeException("boom"); + }); + } catch (RuntimeException ignored) { + // expected + } + assertEquals(1, client.eventsNamed("$ld:ai:duration:total").size()); + } + + @Test + public void successAndErrorShareAtMostOnceState() { + tracker.trackSuccess(); + tracker.trackError(); + assertEquals(1, client.eventsNamed("$ld:ai:generation:success").size()); + assertEquals(0, client.eventsNamed("$ld:ai:generation:error").size()); + assertEquals(Boolean.TRUE, tracker.getSummary().getSuccess()); + } + + @Test + public void trackFeedbackPositive() { + tracker.trackFeedback(FeedbackKind.POSITIVE); + assertEquals(1, client.eventsNamed("$ld:ai:feedback:user:positive").size()); + assertEquals(FeedbackKind.POSITIVE, tracker.getSummary().getFeedback()); + } + + @Test + public void trackFeedbackNegative() { + tracker.trackFeedback(FeedbackKind.NEGATIVE); + assertEquals(1, client.eventsNamed("$ld:ai:feedback:user:negative").size()); + } + + @Test + public void trackTokensOnlyEmitsPositiveCounts() { + tracker.trackTokens(new TokenUsage(10, 0, 6)); + assertEquals(1, client.eventsNamed("$ld:ai:tokens:total").size()); + assertEquals(0, client.eventsNamed("$ld:ai:tokens:input").size()); + assertEquals(1, client.eventsNamed("$ld:ai:tokens:output").size()); + assertEquals(10.0, single("$ld:ai:tokens:total").metricValue, 0.0); + assertEquals(6.0, single("$ld:ai:tokens:output").metricValue, 0.0); + } + + @Test + public void trackTimeToFirstToken() { + tracker.trackTimeToFirstToken(42); + assertEquals(42.0, single("$ld:ai:tokens:ttf").metricValue, 0.0); + assertEquals(Long.valueOf(42), tracker.getSummary().getTimeToFirstTokenMs()); + } + + @Test + public void trackToolCallsEmitsEventPerToolWithToolKey() { + tracker.trackToolCalls(Arrays.asList("search", "weather")); + assertEquals(2, client.eventsNamed("$ld:ai:tool_call").size()); + assertEquals("search", client.eventsNamed("$ld:ai:tool_call").get(0).data.get("toolKey").stringValue()); + assertEquals(Arrays.asList("search", "weather"), tracker.getSummary().getToolCalls()); + } + + @Test + public void trackJudgeResultEmitsScoredEvent() { + JudgeResult result = JudgeResult.builder() + .sampled(true) + .success(true) + .metricKey("$ld:ai:judge:relevance") + .judgeConfigKey("relevance-judge") + .score(0.9) + .build(); + tracker.trackJudgeResult(result); + MockLDClient.TrackEvent event = single("$ld:ai:judge:relevance"); + assertEquals(0.9, event.metricValue, 0.0); + assertEquals("relevance-judge", event.data.get("judgeConfigKey").stringValue()); + } + + @Test + public void trackJudgeResultIgnoresUnsampledOrFailed() { + tracker.trackJudgeResult(JudgeResult.notSampled()); + tracker.trackJudgeResult(JudgeResult.builder().sampled(true).success(false) + .metricKey("$ld:ai:judge:relevance").build()); + assertTrue(client.eventsNamed("$ld:ai:judge:relevance").isEmpty()); + } + + @Test + public void trackMetricsOfRecordsDurationSuccessAndTokens() { + String result = tracker.trackMetricsOf( + value -> AIMetrics.builder(true).tokens(new TokenUsage(10, 4, 6)).build(), + () -> "ok"); + assertEquals("ok", result); + assertEquals(1, client.eventsNamed("$ld:ai:duration:total").size()); + assertEquals(1, client.eventsNamed("$ld:ai:generation:success").size()); + assertEquals(1, client.eventsNamed("$ld:ai:tokens:total").size()); + } + + @Test + public void trackMetricsOfUsesProvidedDuration() { + tracker.trackMetricsOf( + value -> AIMetrics.builder(true).durationMs(555L).build(), + () -> "ok"); + assertEquals(555.0, single("$ld:ai:duration:total").metricValue, 0.0); + } + + @Test + public void trackMetricsOfRecordsErrorAndRethrowsOnException() { + try { + tracker.trackMetricsOf(value -> AIMetrics.builder(true).build(), () -> { + throw new IllegalStateException("boom"); + }); + } catch (IllegalStateException expected) { + assertEquals("boom", expected.getMessage()); + } + assertEquals(1, client.eventsNamed("$ld:ai:duration:total").size()); + assertEquals(1, client.eventsNamed("$ld:ai:generation:error").size()); + } + + @Test + public void summaryCarriesResumptionTokenAtConstruction() { + assertEquals(tracker.getResumptionToken(), tracker.getSummary().getResumptionToken()); + } + + @Test + public void fromResumptionTokenPreservesRunIdentity() { + String token = tracker.getResumptionToken(); + AIConfigTracker restored = AIConfigTracker.fromResumptionToken(token, client, context); + restored.trackSuccess(); + + MockLDClient.TrackEvent event = single("$ld:ai:generation:success"); + assertEquals("run-1", event.data.get("runId").stringValue()); + assertEquals("my-config", event.data.get("configKey").stringValue()); + assertEquals(7, event.data.get("version").intValue()); + assertEquals("var-1", event.data.get("variationKey").stringValue()); + // Model and provider names are not carried in the token. + assertEquals("", event.data.get("modelName").stringValue()); + assertEquals("", event.data.get("providerName").stringValue()); + } + + @Test + public void graphKeyIsIncludedWhenSet() { + AIConfigTracker graphTracker = AIConfigTracker.builder(client) + .runId("run-2") + .configKey("node-config") + .version(1) + .context(context) + .graphKey("graph-1") + .build(); + graphTracker.trackSuccess(); + assertEquals("graph-1", single("$ld:ai:generation:success").data.get("graphKey").stringValue()); + } + + @Test + public void omitsEmptyVariationKeyFromTrackData() { + AIConfigTracker noVariation = AIConfigTracker.builder(client) + .runId("run-3") + .configKey("c") + .version(1) + .context(context) + .build(); + noVariation.trackSuccess(); + assertTrue(single("$ld:ai:generation:success").data.get("variationKey").isNull()); + } +} diff --git a/release-please-config.json b/release-please-config.json index 6b9cf398..6bd0ce12 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,6 +3,15 @@ "separate-pull-requests": true, "include-component-in-tag": true, "packages": { + "lib/java-server-sdk-ai": { + "package-name": "launchdarkly-java-server-sdk-ai", + "bump-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "gradle.properties", + "src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java" + ] + }, "lib/java-server-sdk-otel": { "package-name": "lib/java-server-sdk-otel", "bump-minor-pre-major": true,