diff --git a/braintrust-api/build.gradle b/braintrust-api/build.gradle new file mode 100644 index 00000000..0a00a640 --- /dev/null +++ b/braintrust-api/build.gradle @@ -0,0 +1,151 @@ +// Placeholder subproject for the Braintrust OpenAPI-generated client. +// +// The fetchOpenApiSpec task downloads openapi/spec.yaml from the pinned commit +// of https://github.com/braintrustdata/braintrust-openapi into +// build/openapi/spec.yaml, then generateOpenApiSources runs the OpenAPI +// generator (native Java library) to produce sources under +// build/generated/openapi/src/main/java, which are compiled as part of the +// main source set. +// +// Future work will wire this generated client in to replace the hand-rolled +// BraintrustApiClient.HttpImpl. +// +// Pin the ref in gradle.properties: +// braintrustOpenApiRef= +// +// To point at a local checkout instead of fetching: +// BRAINTRUST_OPENAPI_ROOT=/path/to/braintrust-openapi ./gradlew braintrust-api:compileJava + +plugins { + id 'java' + id 'org.openapi.generator' version '7.14.0' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +// ── Spec fetch ─────────────────────────────────────────────────────────────── + +def openApiRef = project.property('braintrustOpenApiRef') +def openApiSpecDir = layout.buildDirectory.dir('openapi') +def openApiSpecFile = openApiSpecDir.map { it.file('spec.yaml') } + +tasks.register('fetchOpenApiSpec', Exec) { + description = 'Fetches openapi/spec.yaml from the pinned braintrust-openapi ref.' + group = 'Build' + + def localRoot = System.getenv('BRAINTRUST_OPENAPI_ROOT') + + inputs.property('openApiRef', openApiRef) + outputs.dir(openApiSpecDir) + + def outDir = openApiSpecDir.get().asFile + + if (localRoot) { + commandLine 'bash', '-c', + "mkdir -p '${outDir}' && cp '${localRoot}/openapi/spec.yaml' '${outDir}/spec.yaml'" + } else { + commandLine 'bash', "${rootProject.projectDir}/scripts/openapi-fetch.sh", + openApiRef, outDir.absolutePath + } +} + +// ── Code generation ────────────────────────────────────────────────────────── + +def generatedSourcesDir = layout.buildDirectory.dir('generated/openapi') + +openApiGenerate { + generatorName = 'java' + library = 'native' + + // Custom templates that fix a generator bug where anyOf schemas with + // generic container variants (List, Map) produce invalid Java + // (e.g. `List.class`, `getList()`). See anyof_model.mustache. + templateDir = "${projectDir}/src/main/resources/templates/Java" + + inputSpec = openApiSpecFile.get().asFile.absolutePath + + outputDir = generatedSourcesDir.get().asFile.absolutePath + + apiPackage = 'dev.braintrust.openapi.api' + modelPackage = 'dev.braintrust.openapi.model' + invokerPackage = 'dev.braintrust.openapi' + + configOptions = [ + // Use javax (not jakarta) — we're on Java 17 without EE migration + useJakartaEe : 'false', + // Dates as java.time types + dateLibrary : 'java8', + // Bearer token auth — SDK users supply a Braintrust API key + useRuntimeException : 'true', + serializationLibrary : 'jackson', + + // Skip boilerplate we don't need yet + generateApiTests : 'false', + generateModelTests : 'false', + generateApiDocumentation : 'false', + generateModelDocumentation : 'false', + ] + + // The FunctionData anyOf has an inline schema with title:"prompt" which the generator + // names "Prompt", overwriting the real components/schemas/Prompt full object with a + // stub containing only the type discriminator. Remap it so the real Prompt generates + // correctly as a full POJO with slug, prompt_data, etc. + inlineSchemaNameMappings = [ + 'prompt': 'FunctionDataPrompt', + ] + + // Don't generate a full Maven project skeleton — just the sources + globalProperties = [ + apis : '', + models : '', + supportingFiles : '', + apiTests : 'false', + modelTests : 'false', + apiDocs : 'false', + modelDocs : 'false', + ] + +} + +// Wire fetch → generate → compile +tasks.named('openApiGenerate') { + dependsOn tasks.named('fetchOpenApiSpec') +} + +tasks.named('compileJava') { + dependsOn tasks.named('openApiGenerate') +} + +// Add the generated sources to the main source set +sourceSets { + main { + java { + srcDir generatedSourcesDir.map { it.dir('src/main/java') } + } + } +} + +// ── Dependencies ───────────────────────────────────────────────────────────── + +dependencies { + // Required by the openapi-generator native Java library + implementation "com.fasterxml.jackson.core:jackson-databind:${rootProject.ext.jacksonVersion}" + implementation "com.fasterxml.jackson.core:jackson-annotations:${rootProject.ext.jacksonVersion}" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${rootProject.ext.jacksonVersion}" + implementation 'org.openapitools:jackson-databind-nullable:0.2.10' + + // @Nullable / @javax.annotation annotations used by generated code + compileOnly 'com.google.code.findbugs:jsr305:3.0.2' + compileOnly 'javax.annotation:javax.annotation-api:1.3.2' + + testImplementation "org.junit.jupiter:junit-jupiter-api:${rootProject.ext.junitVersion}" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${rootProject.ext.junitVersion}" +} diff --git a/braintrust-api/src/main/resources/templates/Java/libraries/native/anyof_model.mustache b/braintrust-api/src/main/resources/templates/Java/libraries/native/anyof_model.mustache new file mode 100644 index 00000000..e5630062 --- /dev/null +++ b/braintrust-api/src/main/resources/templates/Java/libraries/native/anyof_model.mustache @@ -0,0 +1,419 @@ +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import {{invokerPackage}}.ApiClient; +import {{invokerPackage}}.JSON; + +{{>additionalModelTypeAnnotations}}{{>generatedAnnotation}}{{>xmlAnnotation}} +@JsonDeserialize(using={{classname}}.{{classname}}Deserializer.class) +@JsonSerialize(using = {{classname}}.{{classname}}Serializer.class) +public class {{classname}} extends AbstractOpenApiSchema{{#vendorExtensions.x-implements}} implements {{{.}}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-implements}} { + private static final Logger log = Logger.getLogger({{classname}}.class.getName()); + + public static class {{classname}}Serializer extends StdSerializer<{{classname}}> { + public {{classname}}Serializer(Class<{{classname}}> t) { + super(t); + } + + public {{classname}}Serializer() { + this(null); + } + + @Override + public void serialize({{classname}} value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonProcessingException { + jgen.writeObject(value.getActualInstance()); + } + } + + public static class {{classname}}Deserializer extends StdDeserializer<{{classname}}> { + public {{classname}}Deserializer() { + this({{classname}}.class); + } + + public {{classname}}Deserializer(Class vc) { + super(vc); + } + + @Override + public {{classname}} deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException { + JsonNode tree = jp.readValueAsTree(); + + Object deserialized = null; + {{#discriminator}} + Class cls = JSON.getClassForElement(tree, new {{classname}}().getClass()); + if (cls != null) { + // When the OAS schema includes a discriminator, use the discriminator value to + // discriminate the anyOf schemas. + // Get the discriminator mapping value to get the class. + deserialized = tree.traverse(jp.getCodec()).readValueAs(cls); + {{classname}} ret = new {{classname}}(); + ret.setActualInstance(deserialized); + return ret; + } + {{/discriminator}} + {{#composedSchemas.anyOf}} + // deserialize {{{dataType}}} + try { + {{#isContainer}}deserialized = tree.traverse(jp.getCodec()).readValueAs(new com.fasterxml.jackson.core.type.TypeReference<{{{dataType}}}>(){});{{/isContainer}}{{^isContainer}}deserialized = tree.traverse(jp.getCodec()).readValueAs({{baseType}}.class);{{/isContainer}} + {{classname}} ret = new {{classname}}(); + ret.setActualInstance(deserialized); + ret.resolvedVariantType = SchemaType.{{#title}}{{#lambda.titlecase}}{{title}}{{/lambda.titlecase}}{{/title}}{{^title}}{{#vendorExtensions.x-duplicated-data-type}}{{nameInCamelCase}}{{/vendorExtensions.x-duplicated-data-type}}{{^vendorExtensions.x-duplicated-data-type}}{{baseType}}{{/vendorExtensions.x-duplicated-data-type}}{{/title}}; + return ret; + } catch (Exception e) { + // deserialization failed, continue, log to help debugging + log.log(Level.FINER, "Input data does not match '{{classname}}'", e); + } + + {{/composedSchemas.anyOf}} + throw new IOException(String.format("Failed deserialization for {{classname}}: no match found")); + } + + /** + * Handle deserialization of the 'null' value. + */ + @Override + public {{classname}} getNullValue(DeserializationContext ctxt) throws JsonMappingException { + {{#isNullable}} + return null; + {{/isNullable}} + {{^isNullable}} + throw new JsonMappingException(ctxt.getParser(), "{{classname}} cannot be null"); + {{/isNullable}} + } + } + + // store a list of schema names defined in anyOf + public static final Map> schemas = new HashMap>(); + + /** Set during deserialization to record which variant was actually matched. */ + private SchemaType resolvedVariantType; + + public {{classname}}() { + super("anyOf", {{#isNullable}}Boolean.TRUE{{/isNullable}}{{^isNullable}}Boolean.FALSE{{/isNullable}}); + } +{{> libraries/native/additional_properties }} + {{#additionalPropertiesType}} + /** + * Return true if this {{name}} object is equal to o. + */ + @Override + public boolean equals(Object o) { + return super.equals(o) && Objects.equals(this.additionalProperties, (({{classname}})o).additionalProperties); + } + + @Override + public int hashCode() { + return Objects.hash(getActualInstance(), isNullable(), getSchemaType(), additionalProperties); + } + {{/additionalPropertiesType}} + /** + * Construct a {{classname}} with an explicit variant type. + * Callers must pass the correct {@link SchemaType} to ensure {@link #getVariantType()} + * returns the right value — this is especially important when two variants share the same + * raw type after erasure (e.g. {@code List} and {@code List}). + */ + public {{classname}}(SchemaType type, Object o) { + super("anyOf", {{#isNullable}}Boolean.TRUE{{/isNullable}}{{^isNullable}}Boolean.FALSE{{/isNullable}}); + this.resolvedVariantType = type; + setActualInstance(o); + } + + + static { + {{#composedSchemas.anyOf}} + schemas.put("{{{dataType}}}", {{baseType}}.class); + {{/composedSchemas.anyOf}} + JSON.registerDescendants({{classname}}.class, Collections.unmodifiableMap(schemas)); + {{#discriminator}} + // Initialize and register the discriminator mappings. + Map> mappings = new HashMap>(); + {{#mappedModels}} + mappings.put("{{mappingName}}", {{modelName}}.class); + {{/mappedModels}} + mappings.put("{{name}}", {{classname}}.class); + JSON.registerDiscriminator({{classname}}.class, "{{propertyBaseName}}", mappings); + {{/discriminator}} + } + + @Override + public Map> getSchemas() { + return {{classname}}.schemas; + } + + /** + * Set the instance that matches the anyOf child schema, check + * the instance parameter is valid against the anyOf child schemas: + * {{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}} + * + * It could be an instance of the 'anyOf' schemas. + * The anyOf child schemas may themselves be a composed schema (allOf, anyOf, anyOf). + */ + @Override + public void setActualInstance(Object instance) { + {{#isNullable}} + if (instance == null) { + super.setActualInstance(instance); + return; + } + + {{/isNullable}} + {{#composedSchemas.anyOf}} + if (JSON.isInstanceOf({{baseType}}.class, instance, new HashSet>())) { + super.setActualInstance(instance); + return; + } + + {{/composedSchemas.anyOf}} + throw new RuntimeException("Invalid instance type. Must be {{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}}"); + } + + /** + * Get the actual instance, which can be the following: + * {{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}} + * + * @return The actual instance ({{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}}) + */ + @Override + public Object getActualInstance() { + return super.getActualInstance(); + } + + /** + * Enum of the possible anyOf variant types for {{classname}}. + * Each constant carries the full Java type name (including generics) as {@link #dataType}. + * Use {@link #getVariantType()} to get the type of the current instance, then switch on it + * for exhaustive, compile-time-checked variant handling: + *
+     * switch (instance.getVariantType()) {
+     * {{#composedSchemas.anyOf}}
+     *     case {{#title}}{{#lambda.titlecase}}{{title}}{{/lambda.titlecase}}{{/title}}{{^title}}{{#vendorExtensions.x-duplicated-data-type}}{{nameInCamelCase}}{{/vendorExtensions.x-duplicated-data-type}}{{^vendorExtensions.x-duplicated-data-type}}{{baseType}}{{/vendorExtensions.x-duplicated-data-type}}{{/title}} -> (({{{dataType}}}) instance.getActualInstance());
+     * {{/composedSchemas.anyOf}}
+     * }
+     * 
+ */ + public enum SchemaType { + {{#composedSchemas.anyOf}} + {{#title}}{{#lambda.titlecase}}{{title}}{{/lambda.titlecase}}{{/title}}{{^title}}{{#vendorExtensions.x-duplicated-data-type}}{{nameInCamelCase}}{{/vendorExtensions.x-duplicated-data-type}}{{^vendorExtensions.x-duplicated-data-type}}{{baseType}}{{/vendorExtensions.x-duplicated-data-type}}{{/title}}("{{{dataType}}}"){{^-last}},{{/-last}} + {{/composedSchemas.anyOf}}; + + /** The full Java type name of this variant, including any generic parameters. */ + public final String dataType; + + SchemaType(String dataType) { this.dataType = dataType; } + } + + /** + * Return which anyOf variant is currently held. + * + *

For instances created via deserialization or the + * {@link #{{classname}}(SchemaType, Object)} constructor, this is always accurate — + * including cases where two variants share the same erased type (e.g. + * {@code List} vs {@code List}). + * + * @throws IllegalStateException if this instance was created with the no-arg constructor + * and {@code setActualInstance} was called directly (use the + * {@link #{{classname}}(SchemaType, Object)} constructor instead) + */ + public SchemaType getVariantType() { + if (resolvedVariantType == null) { + throw new IllegalStateException( + "resolvedVariantType is not set. Use new {{classname}}(SchemaType, Object) " + + "instead of setActualInstance() to ensure the variant type is recorded."); + } + return resolvedVariantType; + } + + {{#composedSchemas.anyOf}} + /** + * Get the actual instance of `{{{dataType}}}`. If the actual instance is not `{{{dataType}}}`, + * the ClassCastException will be thrown. + * + * @return The actual instance of `{{{dataType}}}` + * @throws ClassCastException if the instance is not `{{{dataType}}}` + */ + @SuppressWarnings("unchecked") + public {{{dataType}}} get{{#title}}{{#lambda.titlecase}}{{title}}{{/lambda.titlecase}}{{/title}}{{^title}}{{#vendorExtensions.x-duplicated-data-type}}{{nameInCamelCase}}{{/vendorExtensions.x-duplicated-data-type}}{{^vendorExtensions.x-duplicated-data-type}}{{baseType}}{{/vendorExtensions.x-duplicated-data-type}}{{/title}}Instance() throws ClassCastException { + return ({{{dataType}}})super.getActualInstance(); + } + + {{/composedSchemas.anyOf}} + +{{#supportUrlQuery}} + + /** + * Convert the instance into URL query string. + * + * @return URL query string + */ + public String toUrlQueryString() { + return toUrlQueryString(null); + } + + /** + * Convert the instance into URL query string. + * + * @param prefix prefix of the query string + * @return URL query string + */ + public String toUrlQueryString(String prefix) { + String suffix = ""; + String containerSuffix = ""; + String containerPrefix = ""; + if (prefix == null) { + // style=form, explode=true, e.g. /pet?name=cat&type=manx + prefix = ""; + } else { + // deepObject style e.g. /pet?id[name]=cat&id[type]=manx + prefix = prefix + "["; + suffix = "]"; + containerSuffix = "]"; + containerPrefix = "["; + } + + StringJoiner joiner = new StringJoiner("&"); + + {{#composedSchemas.oneOf}} + {{^vendorExtensions.x-duplicated-data-type}} + if (getActualInstance() instanceof {{{dataType}}}) { + {{#isArray}} + {{#items.isPrimitiveType}} + {{#uniqueItems}} + if (getActualInstance() != null) { + int i = 0; + for ({{{items.dataType}}} _item : ({{{dataType}}})getActualInstance()) { + joiner.add(String.format("%s{{baseName}}%s%s=%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, i, containerSuffix), + ApiClient.urlEncode(String.valueOf(_item)))); + } + i++; + } + {{/uniqueItems}} + {{^uniqueItems}} + if (getActualInstance() != null) { + for (int i = 0; i < (({{{dataType}}})getActualInstance()).size(); i++) { + joiner.add(String.format("%s{{baseName}}%s%s=%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, i, containerSuffix), + ApiClient.urlEncode(String.valueOf(getActualInstance().get(i))))); + } + } + {{/uniqueItems}} + {{/items.isPrimitiveType}} + {{^items.isPrimitiveType}} + {{#items.isModel}} + {{#uniqueItems}} + if (getActualInstance() != null) { + int i = 0; + for ({{{items.dataType}}} _item : ({{{dataType}}})getActualInstance()) { + if (_item != null) { + joiner.add(_item.toUrlQueryString(String.format("%s{{baseName}}%s%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, i, containerSuffix)))); + } + } + i++; + } + {{/uniqueItems}} + {{^uniqueItems}} + if (getActualInstance() != null) { + for (int i = 0; i < (({{{dataType}}})getActualInstance()).size(); i++) { + if ((({{{dataType}}})getActualInstance()).get(i) != null) { + joiner.add((({{{items.dataType}}})getActualInstance()).get(i).toUrlQueryString(String.format("%s{{baseName}}%s%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, i, containerSuffix)))); + } + } + } + {{/uniqueItems}} + {{/items.isModel}} + {{^items.isModel}} + {{#uniqueItems}} + if (getActualInstance() != null) { + int i = 0; + for ({{{items.dataType}}} _item : ({{{dataType}}})getActualInstance()) { + if (_item != null) { + joiner.add(String.format("%s{{baseName}}%s%s=%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, i, containerSuffix), + ApiClient.urlEncode(String.valueOf(_item)))); + } + i++; + } + } + {{/uniqueItems}} + {{^uniqueItems}} + if (getActualInstance() != null) { + for (int i = 0; i < (({{{dataType}}})getActualInstance()).size(); i++) { + if (getActualInstance().get(i) != null) { + joiner.add(String.format("%s{{baseName}}%s%s=%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, i, containerSuffix), + ApiClient.urlEncode(String.valueOf((({{{dataType}}})getActualInstance()).get(i))))); + } + } + } + {{/uniqueItems}} + {{/items.isModel}} + {{/items.isPrimitiveType}} + {{/isArray}} + {{^isArray}} + {{#isMap}} + {{#items.isPrimitiveType}} + if (getActualInstance() != null) { + for (String _key : (({{{dataType}}})getActualInstance()).keySet()) { + joiner.add(String.format("%s{{baseName}}%s%s=%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, _key, containerSuffix), + getActualInstance().get(_key), ApiClient.urlEncode(String.valueOf((({{{dataType}}})getActualInstance()).get(_key))))); + } + } + {{/items.isPrimitiveType}} + {{^items.isPrimitiveType}} + if (getActualInstance() != null) { + for (String _key : (({{{dataType}}})getActualInstance()).keySet()) { + if ((({{{dataType}}})getActualInstance()).get(_key) != null) { + joiner.add((({{{items.dataType}}})getActualInstance()).get(_key).toUrlQueryString(String.format("%s{{baseName}}%s%s", prefix, suffix, + "".equals(suffix) ? "" : String.format("%s%d%s", containerPrefix, _key, containerSuffix)))); + } + } + } + {{/items.isPrimitiveType}} + {{/isMap}} + {{^isMap}} + {{#isPrimitiveType}} + if (getActualInstance() != null) { + joiner.add(String.format("%s{{{baseName}}}%s=%s", prefix, suffix, ApiClient.urlEncode(String.valueOf(getActualInstance())))); + } + {{/isPrimitiveType}} + {{^isPrimitiveType}} + {{#isModel}} + if (getActualInstance() != null) { + joiner.add((({{{dataType}}})getActualInstance()).toUrlQueryString(prefix + "{{{baseName}}}" + suffix)); + } + {{/isModel}} + {{^isModel}} + if (getActualInstance() != null) { + joiner.add(String.format("%s{{{baseName}}}%s=%s", prefix, suffix, ApiClient.urlEncode(String.valueOf(getActualInstance())))); + } + {{/isModel}} + {{/isPrimitiveType}} + {{/isMap}} + {{/isArray}} + return joiner.toString(); + } + {{/vendorExtensions.x-duplicated-data-type}} + {{/composedSchemas.oneOf}} + return null; + } +{{/supportUrlQuery}} + +} diff --git a/braintrust-api/src/main/resources/templates/Java/libraries/native/api.mustache b/braintrust-api/src/main/resources/templates/Java/libraries/native/api.mustache new file mode 100644 index 00000000..6d89b929 --- /dev/null +++ b/braintrust-api/src/main/resources/templates/Java/libraries/native/api.mustache @@ -0,0 +1,638 @@ +{{>licenseInfo}} +package {{package}}; + +import {{invokerPackage}}.ApiClient; +import {{invokerPackage}}.ApiException; +import {{invokerPackage}}.ApiResponse; +import {{invokerPackage}}.Configuration; +import {{invokerPackage}}.Pair; + +{{#imports}} +import {{import}}; +{{/imports}} + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +{{#useBeanValidation}} +import {{javaxPackage}}.validation.constraints.*; +import {{javaxPackage}}.validation.Valid; + +{{/useBeanValidation}} +{{#hasFormParamsInSpec}} +import org.apache.http.HttpEntity; +import org.apache.http.NameValuePair; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; + +{{/hasFormParamsInSpec}} +import java.io.InputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.http.HttpRequest; +import java.nio.channels.Channels; +import java.nio.channels.Pipe; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import java.util.ArrayList; +import java.util.StringJoiner; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +{{#asyncNative}} + +import java.util.concurrent.CompletableFuture; +{{/asyncNative}} + +{{>generatedAnnotation}} +{{#operations}} +public class {{classname}} { + private final HttpClient memberVarHttpClient; + private final ObjectMapper memberVarObjectMapper; + private final String memberVarBaseUri; + private final Consumer memberVarInterceptor; + private final Duration memberVarReadTimeout; + private final Consumer> memberVarResponseInterceptor; + private final Consumer> memberVarAsyncResponseInterceptor; + + public {{classname}}() { + this(Configuration.getDefaultApiClient()); + } + + public {{classname}}(ApiClient apiClient) { + memberVarHttpClient = apiClient.getHttpClient(); + memberVarObjectMapper = apiClient.getObjectMapper(); + memberVarBaseUri = apiClient.getBaseUri(); + memberVarInterceptor = apiClient.getRequestInterceptor(); + memberVarReadTimeout = apiClient.getReadTimeout(); + memberVarResponseInterceptor = apiClient.getResponseInterceptor(); + memberVarAsyncResponseInterceptor = apiClient.getAsyncResponseInterceptor(); + } + {{#asyncNative}} + + private ApiException getApiException(String operationId, HttpResponse response) { + String message = formatExceptionMessage(operationId, response.statusCode(), response.body()); + return new ApiException(response.statusCode(), message, response.headers(), response.body()); + } + {{/asyncNative}} + {{^asyncNative}} + + protected ApiException getApiException(String operationId, HttpResponse response) throws IOException { + String body = response.body() == null ? null : new String(response.body().readAllBytes()); + String message = formatExceptionMessage(operationId, response.statusCode(), body); + return new ApiException(response.statusCode(), message, response.headers(), body); + } + {{/asyncNative}} + + private String formatExceptionMessage(String operationId, int statusCode, String body) { + if (body == null || body.isEmpty()) { + body = "[no body]"; + } + return operationId + " call failed with: " + statusCode + " - " + body; + } + + {{#operation}} + {{#vendorExtensions.x-group-parameters}} + {{#hasParams}} + /** + * {{summary}} + * {{notes}} + * @param apiRequest {@link API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request} + {{#returnType}} + * @return {{#asyncNative}}CompletableFuture<{{/asyncNative}}{{returnType}}{{#asyncNative}}>{{/asyncNative}} + {{/returnType}} + {{^returnType}} + {{#asyncNative}} + * @return CompletableFuture<Void> + {{/asyncNative}} + {{/returnType}} + * @throws ApiException if fails to make API call + {{#isDeprecated}} + * @deprecated + {{/isDeprecated}} + {{#externalDocs}} + * {{description}} + * @see {{summary}} Documentation + {{/externalDocs}} + */ + {{#isDeprecated}} + @Deprecated + {{/isDeprecated}} + public {{#returnType}}{{#asyncNative}}CompletableFuture<{{{returnType}}}>{{/asyncNative}}{{^asyncNative}}{{{returnType}}}{{/asyncNative}}{{/returnType}}{{^returnType}}{{#asyncNative}}CompletableFuture{{/asyncNative}}{{^asyncNative}}void{{/asyncNative}}{{/returnType}} {{operationId}}(API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request apiRequest) throws ApiException { + {{#allParams}} + {{>nullable_var_annotations}} + {{{dataType}}} {{paramName}} = apiRequest.{{paramName}}(); + {{/allParams}} + {{#returnType}}return {{/returnType}}{{^returnType}}{{#asyncNative}}return {{/asyncNative}}{{/returnType}}{{operationId}}({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + } + + /** + * {{summary}} + * {{notes}} + * @param apiRequest {@link API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request} + * @return {{#asyncNative}}CompletableFuture<{{/asyncNative}}ApiResponse<{{returnType}}{{^returnType}}Void{{/returnType}}>{{#asyncNative}}>{{/asyncNative}} + * @throws ApiException if fails to make API call + {{#isDeprecated}} + * @deprecated + {{/isDeprecated}} + {{#externalDocs}} + * {{description}} + * @see {{summary}} Documentation + {{/externalDocs}} + */ + {{#isDeprecated}} + @Deprecated + {{/isDeprecated}} + public {{#asyncNative}}CompletableFuture<{{/asyncNative}}ApiResponse<{{{returnType}}}{{^returnType}}Void{{/returnType}}>{{#asyncNative}}>{{/asyncNative}} {{operationId}}WithHttpInfo(API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request apiRequest) throws ApiException { + {{#allParams}} + {{{dataType}}} {{paramName}} = apiRequest.{{paramName}}(); + {{/allParams}} + return {{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + } + + {{/hasParams}} + {{/vendorExtensions.x-group-parameters}} + /** + * {{summary}} + * {{notes}} + {{#allParams}} + * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{^isContainer}}{{#defaultValue}}, default to {{.}}{{/defaultValue}}{{/isContainer}}){{/required}} + {{/allParams}} + {{#returnType}} + * @return {{#asyncNative}}CompletableFuture<{{/asyncNative}}{{returnType}}{{#asyncNative}}>{{/asyncNative}} + {{/returnType}} + {{^returnType}} + {{#asyncNative}} + * @return CompletableFuture<Void> + {{/asyncNative}} + {{/returnType}} + * @throws ApiException if fails to make API call + {{#isDeprecated}} + * @deprecated + {{/isDeprecated}} + {{#externalDocs}} + * {{description}} + * @see {{summary}} Documentation + {{/externalDocs}} + */ + {{#isDeprecated}} + @Deprecated + {{/isDeprecated}} + public {{#returnType}}{{#asyncNative}}CompletableFuture<{{{returnType}}}>{{/asyncNative}}{{^asyncNative}}{{{returnType}}}{{/asyncNative}}{{/returnType}}{{^returnType}}{{#asyncNative}}CompletableFuture{{/asyncNative}}{{^asyncNative}}void{{/asyncNative}}{{/returnType}} {{operationId}}({{#allParams}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws ApiException { + {{^asyncNative}} + {{#returnType}}ApiResponse<{{{.}}}> localVarResponse = {{/returnType}}{{operationId}}WithHttpInfo({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + {{#returnType}} + return localVarResponse.getData(); + {{/returnType}} + {{/asyncNative}} + {{#asyncNative}} + try { + HttpRequest.Builder localVarRequestBuilder = {{operationId}}RequestBuilder({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + return memberVarHttpClient.sendAsync( + localVarRequestBuilder.build(), + HttpResponse.BodyHandlers.ofString()).thenComposeAsync(localVarResponse -> { + if (localVarResponse.statusCode()/ 100 != 2) { + return CompletableFuture.failedFuture(getApiException("{{operationId}}", localVarResponse)); + } + {{#returnType}} + try { + String responseBody = localVarResponse.body(); + return CompletableFuture.completedFuture( + responseBody == null || responseBody.isBlank() ? null : memberVarObjectMapper.readValue(responseBody, new TypeReference<{{{returnType}}}>() {}) + ); + } catch (IOException e) { + return CompletableFuture.failedFuture(new ApiException(e)); + } + {{/returnType}} + {{^returnType}} + return CompletableFuture.completedFuture(null); + {{/returnType}} + }); + } + catch (ApiException e) { + return CompletableFuture.failedFuture(e); + } + {{/asyncNative}} + } + + /** + * {{summary}} + * {{notes}} + {{#allParams}} + * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{^isContainer}}{{#defaultValue}}, default to {{.}}{{/defaultValue}}{{/isContainer}}){{/required}} + {{/allParams}} + * @return {{#asyncNative}}CompletableFuture<{{/asyncNative}}ApiResponse<{{returnType}}{{^returnType}}Void{{/returnType}}>{{#asyncNative}}>{{/asyncNative}} + * @throws ApiException if fails to make API call + {{#isDeprecated}} + * @deprecated + {{/isDeprecated}} + {{#externalDocs}} + * {{description}} + * @see {{summary}} Documentation + {{/externalDocs}} + */ + {{#isDeprecated}} + @Deprecated + {{/isDeprecated}} + public {{#asyncNative}}CompletableFuture<{{/asyncNative}}ApiResponse<{{{returnType}}}{{^returnType}}Void{{/returnType}}>{{#asyncNative}}>{{/asyncNative}} {{operationId}}WithHttpInfo({{#allParams}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws ApiException { + {{^asyncNative}} + HttpRequest.Builder localVarRequestBuilder = {{operationId}}RequestBuilder({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + try { + HttpResponse localVarResponse = memberVarHttpClient.send( + localVarRequestBuilder.build(), + HttpResponse.BodyHandlers.ofInputStream()); + if (memberVarResponseInterceptor != null) { + memberVarResponseInterceptor.accept(localVarResponse); + } + try { + if (localVarResponse.statusCode()/ 100 != 2) { + throw getApiException("{{operationId}}", localVarResponse); + } + {{#vendorExtensions.x-java-text-plain-string}} + // for plain text response + if (localVarResponse.headers().map().containsKey("Content-Type") && + "text/plain".equalsIgnoreCase(localVarResponse.headers().map().get("Content-Type").get(0).split(";")[0].trim())) { + java.util.Scanner s = new java.util.Scanner(localVarResponse.body()).useDelimiter("\\A"); + String responseBodyText = s.hasNext() ? s.next() : ""; + return new ApiResponse( + localVarResponse.statusCode(), + localVarResponse.headers().map(), + responseBodyText + ); + } else { + throw new RuntimeException("Error! The response Content-Type is supposed to be `text/plain` but it's not: " + localVarResponse); + } + {{/vendorExtensions.x-java-text-plain-string}} + {{^vendorExtensions.x-java-text-plain-string}} + {{#returnType}} + {{! Fix for https://github.com/OpenAPITools/openapi-generator/issues/13968 }} + {{! This part had a bugfix for an empty response in the past, but this part of that PR was reverted because it was not doing anything. }} + {{! Keep this documentation here, because the problem is not obvious. }} + {{! `InputStream.available()` was used, but that only works for inputstreams that are already in memory, it will not give the right result if it is a remote stream. We only work with remote streams here. }} + {{! https://github.com/OpenAPITools/openapi-generator/pull/13993/commits/3e!37411d2acef0311c82e6d941a8e40b3bc0b6da }} + {{! The `available` method would work with a `PushbackInputStream`, because we could read 1 byte to check if it exists then push it back so Jackson can read it again. The issue with that is that it will also insert an ascii character for "head of input" and that will break Jackson as it does not handle special whitespace characters. }} + {{! A fix for that problem is to read it into a string and remove those characters, but if we need to read it before giving it to jackson to fix the string then just reading it into a string as is to do an emptiness check is the cleaner solution. }} + {{! We could also manipulate the inputstream to remove that bad character, but string manipulation is easier to read and this codepath is not asyncronus so we do not gain anything by reading the stream later. }} + {{! This fix does make it unsuitable for large amounts of data because `InputStream.readAllbytes` is not meant for it, but a synchronous client is already not the right tool for that.}} + if (localVarResponse.body() == null) { + return new ApiResponse<{{{returnType}}}>( + localVarResponse.statusCode(), + localVarResponse.headers().map(), + null + ); + } + + String responseBody = new String(localVarResponse.body().readAllBytes()); + localVarResponse.body().close(); + + return new ApiResponse<{{{returnType}}}>( + localVarResponse.statusCode(), + localVarResponse.headers().map(), + responseBody.isBlank()? null: memberVarObjectMapper.readValue(responseBody, new TypeReference<{{{returnType}}}>() {}) + ); + {{/returnType}} + {{^returnType}} + return new ApiResponse<{{{returnType}}}>( + localVarResponse.statusCode(), + localVarResponse.headers().map(), + null + ); + {{/returnType}} + {{/vendorExtensions.x-java-text-plain-string}} + } finally { + {{^returnType}} + // Drain the InputStream + while (localVarResponse.body().read() != -1) { + // Ignore + } + localVarResponse.body().close(); + {{/returnType}} + } + } catch (IOException e) { + throw new ApiException(e); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ApiException(e); + } + {{/asyncNative}} + {{#asyncNative}} + try { + HttpRequest.Builder localVarRequestBuilder = {{operationId}}RequestBuilder({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}); + return memberVarHttpClient.sendAsync( + localVarRequestBuilder.build(), + HttpResponse.BodyHandlers.ofString()).thenComposeAsync(localVarResponse -> { + if (memberVarAsyncResponseInterceptor != null) { + memberVarAsyncResponseInterceptor.accept(localVarResponse); + } + if (localVarResponse.statusCode()/ 100 != 2) { + return CompletableFuture.failedFuture(getApiException("{{operationId}}", localVarResponse)); + } + {{#returnType}} + try { + String responseBody = localVarResponse.body(); + return CompletableFuture.completedFuture( + new ApiResponse<{{{returnType}}}>( + localVarResponse.statusCode(), + localVarResponse.headers().map(), + responseBody == null || responseBody.isBlank() ? null : memberVarObjectMapper.readValue(responseBody, new TypeReference<{{{returnType}}}>() {})) + ); + } catch (IOException e) { + return CompletableFuture.failedFuture(new ApiException(e)); + } + {{/returnType}} + {{^returnType}} + return CompletableFuture.completedFuture( + new ApiResponse(localVarResponse.statusCode(), localVarResponse.headers().map(), null) + ); + {{/returnType}} + } + ); + } + catch (ApiException e) { + return CompletableFuture.failedFuture(e); + } + {{/asyncNative}} + } + + private HttpRequest.Builder {{operationId}}RequestBuilder({{#allParams}}{{>nullable_var_annotations}} {{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) throws ApiException { + {{#allParams}} + {{#required}} + // verify the required parameter '{{paramName}}' is set + if ({{paramName}} == null) { + throw new ApiException(400, "Missing the required parameter '{{paramName}}' when calling {{operationId}}"); + } + {{/required}} + {{/allParams}} + + HttpRequest.Builder localVarRequestBuilder = HttpRequest.newBuilder(); + + {{! Switch delimiters for baseName so we can write constants like "{query}" }} + String localVarPath = "{{{path}}}"{{#pathParams}} + .replace({{=<% %>=}}"{<%baseName%>}"<%={{ }}=%>, ApiClient.urlEncode({{{paramName}}}.toString())){{/pathParams}}; + + {{#hasQueryParams}} + List localVarQueryParams = new ArrayList<>(); + StringJoiner localVarQueryStringJoiner = new StringJoiner("&"); + String localVarQueryParameterBaseName; + {{#queryParams}} + localVarQueryParameterBaseName = "{{{baseName}}}"; + {{#collectionFormat}} + localVarQueryParams.addAll(ApiClient.parameterToPairs("{{{collectionFormat}}}", "{{baseName}}", {{paramName}})); + {{/collectionFormat}} + {{^collectionFormat}} + {{#isDeepObject}} + if ({{paramName}} != null) { + {{#isArray}} + for (int i=0; i < {{paramName}}.size(); i++) { + localVarQueryStringJoiner.add({{paramName}}.get(i).toUrlQueryString(String.format("{{baseName}}[%d]", i))); + } + {{/isArray}} + {{^isArray}} + String queryString = {{paramName}}.toUrlQueryString("{{baseName}}"); + if (!queryString.isBlank()) { + localVarQueryStringJoiner.add(queryString); + } + {{/isArray}} + } + {{/isDeepObject}} + {{^isDeepObject}} + {{#isExplode}} + {{#hasVars}} + {{#vars}} + {{#isArray}} + localVarQueryParams.addAll(ApiClient.parameterToPairs("multi", "{{baseName}}", {{paramName}}.{{getter}}())); + {{/isArray}} + {{^isArray}} + localVarQueryParams.addAll(ApiClient.parameterToPairs("{{baseName}}", {{paramName}}.{{getter}}())); + {{/isArray}} + {{/vars}} + {{/hasVars}} + {{^hasVars}} + {{#isModel}} + if ({{paramName}} != null) { + String queryString = {{paramName}}.toUrlQueryString(); + if (queryString != null && !queryString.isBlank()) { + localVarQueryStringJoiner.add(queryString); + } + } + {{/isModel}} + {{^isModel}} + localVarQueryParams.addAll(ApiClient.parameterToPairs("{{baseName}}", {{paramName}})); + {{/isModel}} + {{/hasVars}} + {{/isExplode}} + {{^isExplode}} + localVarQueryParams.addAll(ApiClient.parameterToPairs("{{baseName}}", {{paramName}})); + {{/isExplode}} + {{/isDeepObject}} + {{/collectionFormat}} + {{/queryParams}} + + if (!localVarQueryParams.isEmpty() || localVarQueryStringJoiner.length() != 0) { + StringJoiner queryJoiner = new StringJoiner("&"); + localVarQueryParams.forEach(p -> queryJoiner.add(p.getName() + '=' + p.getValue())); + if (localVarQueryStringJoiner.length() != 0) { + queryJoiner.add(localVarQueryStringJoiner.toString()); + } + localVarRequestBuilder.uri(URI.create(memberVarBaseUri + localVarPath + '?' + queryJoiner.toString())); + } else { + localVarRequestBuilder.uri(URI.create(memberVarBaseUri + localVarPath)); + } + {{/hasQueryParams}} + {{^hasQueryParams}} + localVarRequestBuilder.uri(URI.create(memberVarBaseUri + localVarPath)); + {{/hasQueryParams}} + + {{#headerParams}} + if ({{paramName}} != null) { + localVarRequestBuilder.header("{{baseName}}", {{paramName}}.toString()); + } + {{/headerParams}} + {{#bodyParam}} + localVarRequestBuilder.header("Content-Type", "{{#hasConsumes}}{{#consumes}}{{#-first}}{{{mediaType}}}{{/-first}}{{/consumes}}{{/hasConsumes}}{{#hasConsumes}}{{^consumes}}application/json{{/consumes}}{{/hasConsumes}}{{^hasConsumes}}application/json{{/hasConsumes}}"); + {{/bodyParam}} + localVarRequestBuilder.header("Accept", "{{#hasProduces}}{{#produces}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/produces}}{{/hasProduces}}{{#hasProduces}}{{^produces}}application/json{{/produces}}{{/hasProduces}}{{^hasProduces}}application/json{{/hasProduces}}"); + + {{#bodyParam}} + {{#isString}} + localVarRequestBuilder.method("{{httpMethod}}", HttpRequest.BodyPublishers.ofString({{paramName}})); + {{/isString}} + {{^isString}} + try { + byte[] localVarPostBody = memberVarObjectMapper.writeValueAsBytes({{paramName}}); + localVarRequestBuilder.method("{{httpMethod}}", HttpRequest.BodyPublishers.ofByteArray(localVarPostBody)); + } catch (IOException e) { + throw new ApiException(e); + } + {{/isString}} + {{/bodyParam}} + {{^bodyParam}} + {{#hasFormParams}} + {{#isMultipart}} + MultipartEntityBuilder multiPartBuilder = MultipartEntityBuilder.create(); + boolean hasFiles = false; + {{#formParams}} + {{#isArray}} + for (int i=0; i < {{paramName}}.size(); i++) { + {{#isFile}} + multiPartBuilder.addBinaryBody("{{{baseName}}}", {{paramName}}.get(i)); + hasFiles = true; + {{/isFile}} + {{^isFile}} + if ({{paramName}}.get(i) != null) { + multiPartBuilder.addTextBody("{{{baseName}}}", {{paramName}}.get(i).toString()); + } + {{/isFile}} + } + {{/isArray}} + {{^isArray}} + {{#isFile}} + multiPartBuilder.addBinaryBody("{{{baseName}}}", {{paramName}}); + hasFiles = true; + {{/isFile}} + {{^isFile}} + if ({{paramName}} != null) { + multiPartBuilder.addTextBody("{{{baseName}}}", {{paramName}}.toString()); + } + {{/isFile}} + {{/isArray}} + {{/formParams}} + HttpEntity entity = multiPartBuilder.build(); + HttpRequest.BodyPublisher formDataPublisher; + if (hasFiles) { + Pipe pipe; + try { + pipe = Pipe.open(); + } catch (IOException e) { + throw new RuntimeException(e); + } + new Thread(() -> { + try (OutputStream outputStream = Channels.newOutputStream(pipe.sink())) { + entity.writeTo(outputStream); + } catch (IOException e) { + e.printStackTrace(); + } + }).start(); + formDataPublisher = HttpRequest.BodyPublishers.ofInputStream(() -> Channels.newInputStream(pipe.source())); + } else { + ByteArrayOutputStream formOutputStream = new ByteArrayOutputStream(); + try { + entity.writeTo(formOutputStream); + } catch (IOException e) { + throw new RuntimeException(e); + } + formDataPublisher = HttpRequest.BodyPublishers + .ofInputStream(() -> new ByteArrayInputStream(formOutputStream.toByteArray())); + } + localVarRequestBuilder + .header("Content-Type", entity.getContentType().getValue()) + .method("{{httpMethod}}", formDataPublisher); + {{/isMultipart}} + {{^isMultipart}} + List formValues = new ArrayList<>(); + {{#formParams}} + {{#isArray}} + for (int i=0; i < {{paramName}}.size(); i++) { + if ({{paramName}}.get(i) != null) { + formValues.add(new BasicNameValuePair("{{{baseName}}}", {{paramName}}.get(i).toString())); + } + } + {{/isArray}} + {{^isArray}} + if ({{paramName}} != null) { + formValues.add(new BasicNameValuePair("{{{baseName}}}", {{paramName}}.toString())); + } + {{/isArray}} + {{/formParams}} + HttpEntity entity = new UrlEncodedFormEntity(formValues, java.nio.charset.StandardCharsets.UTF_8); + ByteArrayOutputStream formOutputStream = new ByteArrayOutputStream(); + try { + entity.writeTo(formOutputStream); + } catch (IOException e) { + throw new RuntimeException(e); + } + localVarRequestBuilder + .header("Content-Type", entity.getContentType().getValue()) + .method("{{httpMethod}}", HttpRequest.BodyPublishers + .ofInputStream(() -> new ByteArrayInputStream(formOutputStream.toByteArray()))); + {{/isMultipart}} + {{/hasFormParams}} + {{^hasFormParams}} + localVarRequestBuilder.method("{{httpMethod}}", HttpRequest.BodyPublishers.noBody()); + {{/hasFormParams}} + {{/bodyParam}} + if (memberVarReadTimeout != null) { + localVarRequestBuilder.timeout(memberVarReadTimeout); + } + if (memberVarInterceptor != null) { + memberVarInterceptor.accept(localVarRequestBuilder); + } + return localVarRequestBuilder; + } + + {{#vendorExtensions.x-group-parameters}} + {{#hasParams}} + + public static final class API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request { + {{#requiredParams}} + {{>nullable_var_annotations}} + private {{{dataType}}} {{paramName}}; // {{description}} (required) + {{/requiredParams}} + {{#optionalParams}} + {{>nullable_var_annotations}} + private {{{dataType}}} {{paramName}}; // {{description}} (optional{{^isContainer}}{{#defaultValue}}, default to {{.}}{{/defaultValue}}{{/isContainer}}) + {{/optionalParams}} + + private API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request(Builder builder) { + {{#requiredParams}} + this.{{paramName}} = builder.{{paramName}}; + {{/requiredParams}} + {{#optionalParams}} + this.{{paramName}} = builder.{{paramName}}; + {{/optionalParams}} + } + {{#allParams}} + {{>nullable_var_annotations}} + public {{{dataType}}} {{paramName}}() { + return {{paramName}}; + } + {{/allParams}} + public static Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + {{#requiredParams}} + private {{{dataType}}} {{paramName}}; + {{/requiredParams}} + {{#optionalParams}} + private {{{dataType}}} {{paramName}}; + {{/optionalParams}} + + {{#allParams}} + public Builder {{paramName}}({{>nullable_var_annotations}} {{{dataType}}} {{paramName}}) { + this.{{paramName}} = {{paramName}}; + return this; + } + {{/allParams}} + public API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request build() { + return new API{{#lambda.titlecase}}{{operationId}}{{/lambda.titlecase}}Request(this); + } + } + } + + {{/hasParams}} + {{/vendorExtensions.x-group-parameters}} + {{/operation}} +} +{{/operations}} diff --git a/braintrust-api/src/test/java/dev/braintrust/openapi/model/AnyOfModelTemplateTest.java b/braintrust-api/src/test/java/dev/braintrust/openapi/model/AnyOfModelTemplateTest.java new file mode 100644 index 00000000..95f636ea --- /dev/null +++ b/braintrust-api/src/test/java/dev/braintrust/openapi/model/AnyOfModelTemplateTest.java @@ -0,0 +1,264 @@ +package dev.braintrust.openapi.model; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.braintrust.openapi.JSON; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests for the custom anyof_model.mustache template. + * + *

The upstream template has a bug where anyOf schemas with generic container variants (e.g. + * List<Foo>, Map<K,V>) produce invalid Java: {@code List.class} literals and + * {@code getList()} method names. Our override fixes this by switching to {@code baseType} + * (the raw erased type) for .class references. + * + *

Our template also adds: + * + *

    + *
  • A {@code SchemaType} enum per anyOf class for exhaustive switch handling + *
  • {@code getVariantType()} returning the enum constant, accurate even for same-erased + * variants — set during deserialization, or explicitly via the {@code (SchemaType, Object)} + * constructor + *
  • Typed getters named after the enum constant (e.g. {@code getStringInstance()}, {@code + * getWeightedInstance()}) rather than synthetic {@code getanyOf0Instance()} names + *
  • Enum constants named from the spec {@code title} when present (titlecased), falling back to + * {@code baseType} otherwise + *
+ * + *

Test classes: + * + *

    + *
  • {@link AISecretType} — anyOf: [String, List<String>] — no titles, uses baseType names + * ({@code String}, {@code List}) + *
  • {@link ProjectScoreCategories} — anyOf: [List<ProjectScoreCategory>, + * Map<String,BigDecimal>, List<String>] — has titles ({@code categorical}, {@code + * weighted}, {@code minimum}), exercises title-based naming, the single-constructor fix for + * duplicate-erasing types, and accurate {@code getVariantType()} via deserialization and + * explicit constructor + *
+ */ +public class AnyOfModelTemplateTest { + + private static ObjectMapper mapper; + + @BeforeAll + static void setUp() { + mapper = new JSON().getMapper(); + } + + // ── AISecretType: anyOf [String, List] ──────────────────────────── + + @Test + void aiSecretType_setActualInstance_acceptsString() { + AISecretType t = new AISecretType(); + assertDoesNotThrow(() -> t.setActualInstance("openai")); + assertEquals("openai", t.getActualInstance()); + } + + @Test + void aiSecretType_setActualInstance_acceptsList() { + AISecretType t = new AISecretType(); + List list = List.of("openai", "anthropic"); + assertDoesNotThrow(() -> t.setActualInstance(list)); + assertEquals(list, t.getActualInstance()); + } + + @Test + void aiSecretType_setActualInstance_rejectsInvalidType() { + AISecretType t = new AISecretType(); + assertThrows(RuntimeException.class, () -> t.setActualInstance(42)); + } + + @Test + void aiSecretType_typedGetter_string() { + AISecretType t = new AISecretType(AISecretType.SchemaType.String, "anthropic"); + assertEquals("anthropic", t.getStringInstance()); + } + + @Test + void aiSecretType_typedGetter_list() { + AISecretType t = new AISecretType(AISecretType.SchemaType.List, List.of("a", "b")); + List result = t.getListInstance(); + assertEquals(List.of("a", "b"), result); + } + + @Test + void aiSecretType_deserialize_fromStringJson() throws Exception { + AISecretType t = mapper.readValue("\"openai\"", AISecretType.class); + assertNotNull(t); + assertInstanceOf(String.class, t.getActualInstance()); + assertEquals("openai", t.getActualInstance()); + } + + @Test + void aiSecretType_deserialize_fromArrayJson() throws Exception { + AISecretType t = mapper.readValue("[\"openai\",\"anthropic\"]", AISecretType.class); + assertNotNull(t); + assertInstanceOf(List.class, t.getActualInstance()); + @SuppressWarnings("unchecked") + List list = (List) t.getActualInstance(); + assertEquals(List.of("openai", "anthropic"), list); + } + + @Test + void aiSecretType_roundTrip_string() throws Exception { + AISecretType original = new AISecretType(AISecretType.SchemaType.String, "openai"); + String json = mapper.writeValueAsString(original); + AISecretType roundTripped = mapper.readValue(json, AISecretType.class); + assertEquals(original.getActualInstance(), roundTripped.getActualInstance()); + assertEquals(AISecretType.SchemaType.String, roundTripped.getVariantType()); + } + + @Test + void aiSecretType_getVariantType_viaConstructor_string() { + // No title in spec → enum constant name comes from baseType + AISecretType t = new AISecretType(AISecretType.SchemaType.String, "openai"); + assertEquals(AISecretType.SchemaType.String, t.getVariantType()); + } + + @Test + void aiSecretType_getVariantType_viaConstructor_list() { + AISecretType t = new AISecretType(AISecretType.SchemaType.List, List.of("openai")); + assertEquals(AISecretType.SchemaType.List, t.getVariantType()); + } + + @Test + void aiSecretType_getVariantType_viaDeserialization_string() throws Exception { + AISecretType t = mapper.readValue("\"openai\"", AISecretType.class); + assertEquals(AISecretType.SchemaType.String, t.getVariantType()); + } + + @Test + void aiSecretType_getVariantType_viaDeserialization_list() throws Exception { + AISecretType t = mapper.readValue("[\"openai\"]", AISecretType.class); + assertEquals(AISecretType.SchemaType.List, t.getVariantType()); + } + + @Test + void aiSecretType_getVariantType_throwsIfNotSet() { + // No-arg constructor + setActualInstance does not set resolvedVariantType + AISecretType t = new AISecretType(); + t.setActualInstance("openai"); + assertThrows(IllegalStateException.class, t::getVariantType); + } + + // ── ProjectScoreCategories: anyOf [List, Map, + // List] ────────────────────────── + + @Test + void projectScoreCategories_setActualInstance_acceptsList() { + ProjectScoreCategories c = new ProjectScoreCategories(); + assertDoesNotThrow(() -> c.setActualInstance(List.of())); + } + + @Test + void projectScoreCategories_setActualInstance_acceptsMap() { + ProjectScoreCategories c = new ProjectScoreCategories(); + assertDoesNotThrow(() -> c.setActualInstance(Map.of("accuracy", new BigDecimal("0.8")))); + } + + @Test + void projectScoreCategories_setActualInstance_rejectsInvalidType() { + ProjectScoreCategories c = new ProjectScoreCategories(); + assertThrows(RuntimeException.class, () -> c.setActualInstance("not-valid")); + } + + @Test + void projectScoreCategories_getVariantType_categorical_viaConstructor() { + ProjectScoreCategories c = + new ProjectScoreCategories( + ProjectScoreCategories.SchemaType.Categorical, + List.of(new ProjectScoreCategory())); + assertEquals(ProjectScoreCategories.SchemaType.Categorical, c.getVariantType()); + } + + @Test + void projectScoreCategories_getVariantType_weighted_viaConstructor() { + ProjectScoreCategories c = + new ProjectScoreCategories( + ProjectScoreCategories.SchemaType.Weighted, + Map.of("accuracy", new BigDecimal("0.9"))); + assertEquals(ProjectScoreCategories.SchemaType.Weighted, c.getVariantType()); + } + + @Test + void projectScoreCategories_getVariantType_minimum_viaConstructor() { + // Explicitly declare the List variant as Minimum — this is the key fix. + // Without the (SchemaType, Object) constructor, getVariantType() would incorrectly + // return Categorical for both List variants due to erasure. + ProjectScoreCategories c = + new ProjectScoreCategories( + ProjectScoreCategories.SchemaType.Minimum, List.of("pass", "fail")); + assertEquals(ProjectScoreCategories.SchemaType.Minimum, c.getVariantType()); + } + + @Test + void projectScoreCategories_getVariantType_viaDeserialization_minimum() throws Exception { + // Uses TypeReference> so Jackson fails fast on object elements, + // correctly distinguishing Minimum (List) from Categorical + // (List) + ProjectScoreCategories c = + mapper.readValue("[\"pass\",\"fail\"]", ProjectScoreCategories.class); + assertEquals(ProjectScoreCategories.SchemaType.Minimum, c.getVariantType()); + } + + @Test + void projectScoreCategories_getVariantType_viaDeserialization_categorical() throws Exception { + ProjectScoreCategories c = + mapper.readValue( + "[{\"name\":\"pass\",\"value\":1.0}]", ProjectScoreCategories.class); + assertEquals(ProjectScoreCategories.SchemaType.Categorical, c.getVariantType()); + } + + @Test + void projectScoreCategories_getVariantType_viaDeserialization_weighted() throws Exception { + ProjectScoreCategories c = + mapper.readValue("{\"accuracy\":0.8}", ProjectScoreCategories.class); + assertEquals(ProjectScoreCategories.SchemaType.Weighted, c.getVariantType()); + } + + @Test + void projectScoreCategories_deserialize_fromObjectJson() throws Exception { + ProjectScoreCategories c = + mapper.readValue("{\"accuracy\":0.8,\"recall\":0.6}", ProjectScoreCategories.class); + assertNotNull(c); + assertInstanceOf(Map.class, c.getActualInstance()); + } + + @Test + void projectScoreCategories_roundTrip_map() throws Exception { + ProjectScoreCategories original = + new ProjectScoreCategories( + ProjectScoreCategories.SchemaType.Weighted, + Map.of("accuracy", new BigDecimal("0.9"))); + String json = mapper.writeValueAsString(original); + ProjectScoreCategories roundTripped = mapper.readValue(json, ProjectScoreCategories.class); + assertInstanceOf(Map.class, roundTripped.getActualInstance()); + assertEquals(ProjectScoreCategories.SchemaType.Weighted, roundTripped.getVariantType()); + } + + @Test + void projectScoreCategories_typedGetter_weighted() { + Map weights = Map.of("accuracy", new BigDecimal("0.8")); + ProjectScoreCategories c = + new ProjectScoreCategories(ProjectScoreCategories.SchemaType.Weighted, weights); + assertEquals(weights, c.getWeightedInstance()); + } + + @Test + void projectScoreCategories_schemaType_dataType_field() { + // Each enum constant carries the full generic type as .dataType + assertEquals( + "List", + ProjectScoreCategories.SchemaType.Categorical.dataType); + assertEquals( + "Map", ProjectScoreCategories.SchemaType.Weighted.dataType); + assertEquals("List", ProjectScoreCategories.SchemaType.Minimum.dataType); + } +} diff --git a/braintrust-sdk/build.gradle b/braintrust-sdk/build.gradle index 88279421..eec44e33 100644 --- a/braintrust-sdk/build.gradle +++ b/braintrust-sdk/build.gradle @@ -105,6 +105,8 @@ afterEvaluate { } dependencies { + implementation project(':braintrust-api') + api "io.opentelemetry:opentelemetry-api:${otelVersion}" api "io.opentelemetry:opentelemetry-sdk:${otelVersion}" api "io.opentelemetry:opentelemetry-sdk-trace:${otelVersion}" diff --git a/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java b/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java index a85df960..1da5b49d 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/Braintrust.java @@ -1,6 +1,7 @@ package dev.braintrust; import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.api.BraintrustOpenApiClient; import dev.braintrust.config.BraintrustConfig; import dev.braintrust.eval.Dataset; import dev.braintrust.eval.Eval; @@ -88,8 +89,9 @@ static void resetForTest() { /** Create a new Braintrust instance from the given config */ public static Braintrust of(BraintrustConfig config) { BraintrustApiClient apiClient = BraintrustApiClient.of(config); - BraintrustPromptLoader promptLoader = BraintrustPromptLoader.of(config, apiClient); - return new Braintrust(config, apiClient, promptLoader); + BraintrustOpenApiClient openApiClient = BraintrustOpenApiClient.of(config); + BraintrustPromptLoader promptLoader = BraintrustPromptLoader.of(config, openApiClient); + return new Braintrust(config, apiClient, openApiClient, promptLoader); } @Getter @@ -97,9 +99,14 @@ public static Braintrust of(BraintrustConfig config) { private final BraintrustConfig config; @Getter + @Deprecated @Accessors(fluent = true) private final BraintrustApiClient apiClient; + @Getter + @Accessors(fluent = true) + private final BraintrustOpenApiClient openApiClient; + @Getter @Accessors(fluent = true) private final BraintrustPromptLoader promptLoader; @@ -107,16 +114,17 @@ public static Braintrust of(BraintrustConfig config) { Braintrust( BraintrustConfig config, BraintrustApiClient apiClient, + BraintrustOpenApiClient openApiClient, BraintrustPromptLoader promptLoader) { this.config = config; this.apiClient = apiClient; + this.openApiClient = openApiClient; this.promptLoader = promptLoader; } /** the the URI to the configured braintrust org and project */ public URI projectUri() { - return BraintrustUtils.createProjectURI( - config.appUrl(), apiClient().getOrCreateProjectAndOrgInfo(config())); + return openApiClient.fetchProjectUri(); } /** diff --git a/braintrust-sdk/src/main/java/dev/braintrust/BraintrustUtils.java b/braintrust-sdk/src/main/java/dev/braintrust/BraintrustUtils.java index 4e2fa4ad..77fde7e4 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/BraintrustUtils.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/BraintrustUtils.java @@ -1,6 +1,8 @@ package dev.braintrust; import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.openapi.model.Organization; +import dev.braintrust.openapi.model.Project; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; @@ -12,12 +14,22 @@ public class BraintrustUtils { /** construct a URI to link to a specific braintrust project within an org */ public static URI createProjectURI( String appUrl, BraintrustApiClient.OrganizationAndProjectInfo orgAndProject) { + return createProjectURI( + appUrl, orgAndProject.orgInfo().name(), orgAndProject.project().name()); + } + + /** + * construct a URI to link to a specific braintrust project within an org, using generated types + */ + public static URI createProjectURI(String appUrl, Organization org, Project project) { + return createProjectURI(appUrl, org.getName(), project.getName()); + } + + /** construct a URI to link to a specific braintrust project within an org by name */ + public static URI createProjectURI(String appUrl, String orgName, String projectName) { try { var baseURI = new URI(appUrl); - var path = - "/app/%s/p/%s" - .formatted( - orgAndProject.orgInfo().name(), orgAndProject.project().name()); + var path = "/app/%s/p/%s".formatted(orgName, projectName); return new URI( baseURI.getScheme(), baseURI.getUserInfo(), diff --git a/braintrust-sdk/src/main/java/dev/braintrust/api/ApiException.java b/braintrust-sdk/src/main/java/dev/braintrust/api/ApiException.java index cfe9c289..4c41620f 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/api/ApiException.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/api/ApiException.java @@ -1,7 +1,7 @@ package dev.braintrust.api; +@Deprecated class ApiException extends RuntimeException { - public ApiException(String message) { super(message); } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustApiClient.java b/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustApiClient.java index bd5929ec..a587ed94 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustApiClient.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustApiClient.java @@ -12,7 +12,6 @@ import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -22,7 +21,11 @@ * Provides the necessary API calls for the Braintrust SDK. Users of the SDK should favor using * {@link dev.braintrust.eval.Eval} or {@link dev.braintrust.trace.BraintrustTracing} */ +@Deprecated public interface BraintrustApiClient { + /** TODO don't merge. refactor scaffolding */ + BraintrustOpenApiClient openApiClient(); + /** * Attempt Braintrust login * @@ -171,6 +174,11 @@ public List listExperiments(String projectId) { } } + @Override + public BraintrustOpenApiClient openApiClient() { + return BraintrustOpenApiClient.of(config); + } + @Override public LoginResponse login() throws LoginException { try { @@ -489,237 +497,6 @@ private static HttpClient createDefaultHttpClient(BraintrustConfig config) { } } - /** Implementation for test doubling */ - @Slf4j - class InMemoryImpl implements BraintrustApiClient { - private final List organizationAndProjectInfos; - private final Set experiments = - Collections.newSetFromMap(new ConcurrentHashMap<>()); - private final List prompts = new ArrayList<>(); - private final List functions = new ArrayList<>(); - private final Map> - functionInvokers = new ConcurrentHashMap<>(); - - public InMemoryImpl(OrganizationAndProjectInfo... organizationAndProjectInfos) { - this.organizationAndProjectInfos = - new ArrayList<>(List.of(organizationAndProjectInfos)); - } - - public InMemoryImpl( - List organizationAndProjectInfos, - List prompts) { - this.organizationAndProjectInfos = new ArrayList<>(organizationAndProjectInfos); - this.prompts.addAll(prompts); - } - - @Override - public LoginResponse login() { - return new LoginResponse( - organizationAndProjectInfos.stream().map(o -> o.orgInfo).toList()); - } - - @Override - public Project getOrCreateProject(String projectName) { - // Find existing project by name - for (var orgAndProject : organizationAndProjectInfos) { - if (orgAndProject.project().name().equals(projectName)) { - return orgAndProject.project(); - } - } - - // Create new project if not found - var defaultOrgInfo = - organizationAndProjectInfos.isEmpty() - ? new OrganizationInfo("default-org-id", "Default Organization") - : organizationAndProjectInfos.get(0).orgInfo(); - - var newProject = - new Project( - "project-" + UUID.randomUUID().toString(), - projectName, - defaultOrgInfo.id(), - java.time.Instant.now().toString(), - java.time.Instant.now().toString()); - - organizationAndProjectInfos.add( - new OrganizationAndProjectInfo(defaultOrgInfo, newProject)); - return newProject; - } - - @Override - public Optional getProject(String projectId) { - return organizationAndProjectInfos.stream() - .map(OrganizationAndProjectInfo::project) - .filter(project -> project.id().equals(projectId)) - .findFirst(); - } - - @Override - public Experiment getOrCreateExperiment(CreateExperimentRequest request) { - var existing = - experiments.stream() - .filter(exp -> exp.name().equals(request.name())) - .findFirst(); - if (existing.isPresent()) { - return existing.get(); - } - var newExperiment = - new Experiment( - request.name().hashCode() + "", - request.projectId(), - request.name(), - request.description(), - request.tags().orElse(List.of()), - request.metadata().orElse(Map.of()), - "notused", - "notused", - request.datasetId(), - request.datasetVersion()); - experiments.add(newExperiment); - return newExperiment; - } - - @Override - public List listExperiments(String projectId) { - return experiments.stream().filter(exp -> exp.projectId().equals(projectId)).toList(); - } - - @Override - public Optional getProjectAndOrgInfo() { - return organizationAndProjectInfos.isEmpty() - ? Optional.empty() - : Optional.of(organizationAndProjectInfos.get(0)); - } - - @Override - public Optional getProjectAndOrgInfo(String projectId) { - return organizationAndProjectInfos.stream() - .filter(orgAndProject -> orgAndProject.project().id().equals(projectId)) - .findFirst(); - } - - @Override - public OrganizationAndProjectInfo getOrCreateProjectAndOrgInfo(BraintrustConfig config) { - // Get or create project based on config - Project project; - if (config.defaultProjectId().isPresent()) { - var projectId = config.defaultProjectId().get(); - project = - getProject(projectId) - .orElseThrow( - () -> - new ApiException( - "Project with ID '" - + projectId - + "' not found")); - } else if (config.defaultProjectName().isPresent()) { - var projectName = config.defaultProjectName().get(); - project = getOrCreateProject(projectName); - } else { - throw new ApiException( - "Either project ID or project name must be provided in config"); - } - - // Find the organization info for this project - return organizationAndProjectInfos.stream() - .filter(info -> info.project().id().equals(project.id())) - .findFirst() - .orElseThrow( - () -> - new ApiException( - "Unable to find organization for project: " - + project.id())); - } - - @Override - public Optional getPrompt( - @Nonnull String projectName, @Nonnull String slug, @Nullable String version) { - Objects.requireNonNull(projectName, slug); - List matchingPrompts = - prompts.stream() - .filter( - prompt -> { - // Filter by slug if provided - if (slug != null && !slug.isEmpty()) { - if (!prompt.slug().equals(slug)) { - return false; - } - } - - // Filter by project name if provided - if (projectName != null && !projectName.isEmpty()) { - // Find project by name and check if ID matches - Project project = getOrCreateProject(projectName); - if (!prompt.projectId().equals(project.id())) { - return false; - } - } - - // Filter by version if provided - // Note: Version filtering would require additional metadata - // on Prompt - // For now, we'll skip this as Prompt doesn't have a - // version field - - return true; - }) - .toList(); - - if (matchingPrompts.isEmpty()) { - return Optional.empty(); - } - - if (matchingPrompts.size() > 1) { - throw new ApiException( - "Multiple objects found for slug: " - + slug - + ", projectName: " - + projectName); - } - - return Optional.of(matchingPrompts.get(0)); - } - - // Will add dataset support if needed in unit tests (this is unlikely to be needed though) - @Override - public DatasetFetchResponse fetchDatasetEvents( - String datasetId, DatasetFetchRequest request) { - return new DatasetFetchResponse(List.of(), null); - } - - @Override - public Optional getDataset(String datasetId) { - return Optional.empty(); - } - - @Override - public List queryDatasets(String projectName, String datasetName) { - return List.of(); - } - - @Override - public Optional getFunction( - @Nonnull String projectName, @Nonnull String slug, @Nullable String version) { - throw new RuntimeException("will not be invoked"); - } - - @Override - public Optional getFunctionById(@Nonnull String functionId) { - throw new RuntimeException("will not be invoked"); - } - - @Override - public Object invokeFunction( - @Nonnull String functionId, @Nonnull FunctionInvokeRequest request) { - throw new RuntimeException("will not be invoked"); - } - - @Override - public BtqlQueryResponse btqlQuery(@Nonnull String query) { - throw new RuntimeException("will not be invoked"); - } - } - // Request/Response DTOs record CreateProjectRequest(String name) {} diff --git a/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java b/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java new file mode 100644 index 00000000..d803b03d --- /dev/null +++ b/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java @@ -0,0 +1,156 @@ +package dev.braintrust.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.braintrust.BraintrustUtils; +import dev.braintrust.config.BraintrustConfig; +import dev.braintrust.openapi.ApiClient; +import dev.braintrust.openapi.JSON; +import dev.braintrust.openapi.api.ProjectsApi; +import dev.braintrust.openapi.model.CreateProject; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; + +/** + * Braintrust-configured extension of the generated {@link ApiClient}. + * + *

Handles auth (Bearer token) and base URL automatically. Also provides custom methods for + * endpoints not covered by the generated spec (e.g. {@link #login()}). + */ +public class BraintrustOpenApiClient extends ApiClient { + private static final ObjectMapper MAPPER = new JSON().getMapper(); + + private final BraintrustConfig config; + + private BraintrustOpenApiClient(BraintrustConfig config) { + super(); + this.config = config; + this.updateBaseUri(config.apiUrl()); + this.setRequestInterceptor(req -> req.header("Authorization", "Bearer " + config.apiKey())); + } + + public static BraintrustOpenApiClient of(BraintrustConfig config) { + return new BraintrustOpenApiClient(config); + } + + /** + * Calls {@code POST /api/apikey/login} to retrieve organization info for the current API key. + * This endpoint is not in the OpenAPI spec so it is implemented here as a custom method. + */ + public LoginResponse login() { + try { + var body = MAPPER.writeValueAsString(new LoginRequest(config.apiKey())); + var requestBuilder = + HttpRequest.newBuilder() + .uri(URI.create(config.apiUrl() + "/api/apikey/login")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + + if (getRequestInterceptor() != null) { + getRequestInterceptor().accept(requestBuilder); + } + + HttpResponse response = + getHttpClient() + .send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() / 100 != 2) { + throw new ApiException( + "Login failed with status " + + response.statusCode() + + ": " + + response.body()); + } + return MAPPER.readValue(response.body(), LoginResponse.class); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Look up or create the project from config, resolve the org name via login, and return the + * Braintrust app URI for the project. + */ + public URI fetchProjectUri() { + var projectsApi = new ProjectsApi(this); + + dev.braintrust.openapi.model.Project project; + if (config.defaultProjectId().isPresent()) { + project = + projectsApi.getProjectId( + java.util.UUID.fromString(config.defaultProjectId().get())); + } else { + var projectName = config.defaultProjectName().orElseThrow(); + var existing = + projectsApi.getProject(null, null, null, null, projectName, null).getObjects(); + if (existing != null && !existing.isEmpty()) { + project = existing.get(0); + } else { + project = projectsApi.postProject(new CreateProject().name(projectName)); + } + } + + var orgId = project.getOrgId().toString(); + var orgName = + login().orgInfo().stream() + .filter(o -> o.id().equalsIgnoreCase(orgId)) + .map(OrgInfo::name) + .findFirst() + .orElseThrow( + () -> new ApiException("Unable to find org for project: " + orgId)); + + return BraintrustUtils.createProjectURI(config.appUrl(), orgName, project.getName()); + } + + /** + * Calls {@code POST /btql} to run an arbitrary BTQL query. This endpoint is not in the OpenAPI + * spec so it is implemented here as a custom method. + */ + public BtqlQueryResponse btqlQuery(String query) { + try { + var body = MAPPER.writeValueAsString(new BtqlQueryRequest(query)); + var requestBuilder = + HttpRequest.newBuilder() + .uri(URI.create(config.apiUrl() + "/btql")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + + if (getRequestInterceptor() != null) { + getRequestInterceptor().accept(requestBuilder); + } + + HttpResponse response = + getHttpClient() + .send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() / 100 != 2) { + throw new RuntimeException( + "BTQL query failed with status " + + response.statusCode() + + ": " + + response.body()); + } + + return MAPPER.readValue(response.body(), BtqlQueryResponse.class); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("BTQL query failed", e); + } + } + + public record OrgInfo(String id, String name) {} + + public record BtqlQueryResponse(List> data) {} + + private record LoginRequest(String token) {} + + private record BtqlQueryRequest(String query) {} + + public record LoginResponse(@JsonProperty("org_info") List orgInfo) {} +} diff --git a/braintrust-sdk/src/main/java/dev/braintrust/api/LoginException.java b/braintrust-sdk/src/main/java/dev/braintrust/api/LoginException.java index 2d730b69..66e930e9 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/api/LoginException.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/api/LoginException.java @@ -8,6 +8,7 @@ *

This is a RuntimeException so it doesn't require explicit handling, but callers can catch it * specifically if they want to handle login failures differently from other errors. */ +@Deprecated public class LoginException extends RuntimeException { public LoginException(String message) { super(message); diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java index c87f4055..30d2888e 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java @@ -1,8 +1,7 @@ package dev.braintrust.config; import dev.braintrust.Braintrust; -import dev.braintrust.BraintrustUtils; -import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.api.BraintrustOpenApiClient; import java.net.URI; import java.time.Duration; import java.util.HashMap; @@ -116,9 +115,7 @@ public Optional getBraintrustParentValue() { /** Deprecated. Please use {@link Braintrust#projectUri()} instead */ @Deprecated public URI fetchProjectURI() { - var client = BraintrustApiClient.of(this); - var orgAndProject = client.getProjectAndOrgInfo().orElseThrow(); - return BraintrustUtils.createProjectURI(appUrl(), orgAndProject); + return BraintrustOpenApiClient.of(this).fetchProjectUri(); } public static Builder builder() { diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java index ab15214a..ee42952b 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java @@ -1,6 +1,8 @@ package dev.braintrust.eval; import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.api.BraintrustOpenApiClient; +import dev.braintrust.openapi.api.DatasetsApi; import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -62,33 +64,44 @@ static Dataset of(DatasetCase... c return new DatasetInMemoryImpl<>(List.of(cases)); } + @Deprecated static Dataset fetchFromBraintrust( BraintrustApiClient apiClient, String projectName, String datasetName, @Nullable String datasetVersion) { - var datasets = apiClient.queryDatasets(projectName, datasetName); + return fetchFromBraintrust( + apiClient.openApiClient(), projectName, datasetName, datasetVersion); + } + + static Dataset fetchFromBraintrust( + BraintrustOpenApiClient apiClient, + String projectName, + String datasetName, + @Nullable String datasetVersion) { + var datasetsApi = new DatasetsApi(apiClient); + var objects = + datasetsApi + .getDataset(null, null, null, null, datasetName, projectName, null, null) + .getObjects(); - if (datasets.isEmpty()) { + if (objects.isEmpty()) { throw new RuntimeException( "Dataset not found: project=" + projectName + ", dataset=" + datasetName); } - if (datasets.size() > 1) { + if (objects.size() > 1) { throw new RuntimeException( "Multiple datasets found for project=" + projectName + ", dataset=" + datasetName + ". Found " - + datasets.size() + + objects.size() + " datasets"); } - var dataset = datasets.get(0); - return new DatasetBrainstoreImpl<>( - apiClient, - dataset.id(), - datasetVersion != null ? datasetVersion : dataset.updatedAt()); + var dataset = objects.get(0); + return new DatasetBrainstoreImpl<>(apiClient, dataset.getId().toString(), datasetVersion); } } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java index 0d41a2c6..e583d278 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/DatasetBrainstoreImpl.java @@ -1,24 +1,33 @@ package dev.braintrust.eval; import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.api.BraintrustOpenApiClient; +import dev.braintrust.openapi.api.DatasetsApi; +import dev.braintrust.openapi.model.FetchEventsRequest; import java.util.*; import javax.annotation.Nonnull; import javax.annotation.Nullable; /** A dataset loaded externally from Braintrust using paginated API fetches */ public class DatasetBrainstoreImpl implements Dataset { - private final BraintrustApiClient apiClient; + private final BraintrustOpenApiClient apiClient; private final String datasetId; private final @Nullable String pinnedVersion; private final int batchSize; + @Deprecated // will be removed when the old api client goes away public DatasetBrainstoreImpl( BraintrustApiClient apiClient, String datasetId, @Nullable String datasetVersion) { + this(apiClient.openApiClient(), datasetId, datasetVersion, 512); + } + + public DatasetBrainstoreImpl( + BraintrustOpenApiClient apiClient, String datasetId, @Nullable String datasetVersion) { this(apiClient, datasetId, datasetVersion, 512); } DatasetBrainstoreImpl( - BraintrustApiClient apiClient, + BraintrustOpenApiClient apiClient, String datasetId, @Nullable String datasetVersion, int batchSize) { @@ -62,7 +71,7 @@ private String fetchMaxVersion() { } private class BrainstoreCursor implements Cursor> { - private List> currentBatch; + private List currentBatch; private int currentIndex; private @Nullable String cursor; private boolean exhausted; @@ -85,33 +94,27 @@ public Optional> next() { throw new IllegalStateException("Cursor is closed"); } - // Fetch next batch if we've consumed the current one if (currentIndex >= currentBatch.size() && !exhausted) { fetchNextBatch(); } - // Return empty if no more data if (currentIndex >= currentBatch.size()) { return Optional.empty(); } - // Parse the event into a DatasetCase - Map event = currentBatch.get(currentIndex++); + var event = currentBatch.get(currentIndex++); - INPUT input = (INPUT) event.get("input"); - OUTPUT expected = (OUTPUT) event.get("expected"); + INPUT input = (INPUT) event.getInput(); + OUTPUT expected = (OUTPUT) event.getExpected(); - Map metadata = (Map) event.get("metadata"); - if (metadata == null) { - metadata = Map.of(); - } + var metadataObj = event.getMetadata(); + Map metadata = + metadataObj != null ? metadataObj.getAdditionalProperties() : Map.of(); + if (metadata == null) metadata = Map.of(); - List tags = (List) event.get("tags"); - if (tags == null) { - tags = List.of(); - } + List tags = event.getTags() != null ? event.getTags() : List.of(); - DatasetCase datasetCase = + var datasetCase = new DatasetCase<>( input, expected, @@ -121,26 +124,32 @@ public Optional> next() { new dev.braintrust.Origin( "dataset", Objects.requireNonNull( - (String) event.get("dataset_id")), - Objects.requireNonNull((String) event.get("id")), - Objects.requireNonNull((String) event.get("_xact_id")), + event.getDatasetId() != null + ? event.getDatasetId().toString() + : null), + Objects.requireNonNull(event.getId()), + Objects.requireNonNull(event.getXactId()), Objects.requireNonNull( - (String) event.get("created"))))); + event.getCreated() != null + ? event.getCreated().toString() + : null)))); return Optional.of(datasetCase); } private void fetchNextBatch() { var request = - new BraintrustApiClient.DatasetFetchRequest(batchSize, cursor, cursorVersion); - var response = apiClient.fetchDatasetEvents(datasetId, request); + new FetchEventsRequest().limit(batchSize).cursor(cursor).version(cursorVersion); + + var response = + new DatasetsApi(apiClient) + .postDatasetIdFetch(UUID.fromString(datasetId), request); - currentBatch = new ArrayList<>(response.events()); + currentBatch = new ArrayList<>(response.getEvents()); currentIndex = 0; - cursor = response.cursor(); + cursor = response.getCursor(); - // Mark as exhausted if no cursor or no events returned - if (cursor == null || cursor.isEmpty() || response.events().isEmpty()) { + if (cursor == null || cursor.isEmpty() || response.getEvents().isEmpty()) { exhausted = true; } } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPrompt.java b/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPrompt.java index 56e2ffcd..77cf0158 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPrompt.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPrompt.java @@ -1,88 +1,160 @@ package dev.braintrust.prompt; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.mustachejava.DefaultMustacheFactory; import com.github.mustachejava.Mustache; import com.github.mustachejava.MustacheException; -import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.openapi.JSON; +import dev.braintrust.openapi.model.Chat; +import dev.braintrust.openapi.model.ChatCompletionMessageParam; +import dev.braintrust.openapi.model.ModelParams; +import dev.braintrust.openapi.model.PromptBlockDataNullish; +import dev.braintrust.openapi.model.PromptDataNullish; +import dev.braintrust.openapi.model.PromptOptionsNullish; import java.io.StringReader; import java.io.StringWriter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.annotation.Nullable; public class BraintrustPrompt { - private final BraintrustApiClient.Prompt apiPrompt; + private static final ObjectMapper MAPPER = new JSON().getMapper(); + + private final PromptDataNullish promptData; private final Map defaults; - public BraintrustPrompt(BraintrustApiClient.Prompt apiPrompt) { - this(apiPrompt, Map.of()); + public BraintrustPrompt(PromptDataNullish promptData) { + this(promptData, Map.of()); } - public BraintrustPrompt(BraintrustApiClient.Prompt apiPrompt, Map defaults) { - this.apiPrompt = apiPrompt; + public BraintrustPrompt(PromptDataNullish promptData, Map defaults) { + this.promptData = promptData; this.defaults = defaults; } public List> renderMessages(Map parameters) { - // get promptData->prompt->messages - Map promptData = (Map) apiPrompt.promptData().prompt(); - List> messages = (List>) promptData.get("messages"); - - if (messages == null) { - throw new RuntimeException("No messages found in prompt data"); + if (promptData.getPrompt() == null) { + throw new RuntimeException("No prompt block found in prompt data"); + } + PromptBlockDataNullish blockData = promptData.getPrompt(); + Object blockInstance = blockData.getActualInstance(); + if (!(blockInstance instanceof Chat chat)) { + throw new RuntimeException("Only chat prompts are currently supported"); } List> renderedMessages = new ArrayList<>(); - - for (Map message : messages) { - Map renderedMessage = new HashMap<>(message); - String content = (String) message.get("content"); - + for (ChatCompletionMessageParam param : chat.getMessages()) { + final String role; + final String content = + switch (param.getVariantType()) { + case System -> { + var sys = param.getSystemInstance(); + role = "system"; + yield sys.getContent() != null + ? extractStringContent(sys.getContent().getActualInstance()) + : null; + } + case User -> { + var user = param.getUserInstance(); + role = "user"; + yield user.getContent() != null + ? extractStringContent(user.getContent().getActualInstance()) + : null; + } + case Assistant -> { + var asst = param.getAssistantInstance(); + role = "assistant"; + yield asst.getContent() != null + ? extractStringContent(asst.getContent().getActualInstance()) + : null; + } + case Tool -> { + var tool = param.getToolInstance(); + role = "tool"; + yield tool.getContent() != null + ? extractStringContent(tool.getContent().getActualInstance()) + : null; + } + case Function1 -> { + var fn = param.getFunction1Instance(); + role = "function"; + yield fn.getContent(); + } + case Developer -> { + var dev = param.getDeveloperInstance(); + role = "developer"; + yield dev.getContent() != null + ? extractStringContent(dev.getContent().getActualInstance()) + : null; + } + case Fallback -> { + var fb = param.getFallbackInstance(); + role = fb.getRole() != null ? fb.getRole().getValue() : "unknown"; + yield fb.getContent(); + } + }; + + Map rendered = new HashMap<>(); + rendered.put("role", role); if (content != null) { - String renderedContent = renderTemplate(content, parameters); - renderedMessage.put("content", renderedContent); + rendered.put("content", renderTemplate(content, parameters)); } - - renderedMessages.add(renderedMessage); + renderedMessages.add(rendered); } - return renderedMessages; } public Map getOptions() { - // get map in promptData->options and merge with promptData->options->params - Map options = (Map) apiPrompt.promptData().options(); - - if (options == null) { - return Map.of(); + if (promptData.getOptions() == null) { + return applyDefaults(Map.of()); } + PromptOptionsNullish opts = promptData.getOptions(); Map result = new HashMap<>(); - // Add all top-level options except "params" - for (Map.Entry entry : options.entrySet()) { - if (!"params".equals(entry.getKey())) { - result.put(entry.getKey(), entry.getValue()); - } + if (opts.getModel() != null) { + result.put("model", opts.getModel()); } - - // Merge in the params - Map params = (Map) options.get("params"); - if (params != null) { - result.putAll(params); + if (opts.getPosition() != null) { + result.put("position", opts.getPosition()); } - // Apply defaults for any values not already set - for (Map.Entry defaultEntry : this.defaults.entrySet()) { - if (!result.containsKey(defaultEntry.getKey())) { - result.put(defaultEntry.getKey(), defaultEntry.getValue()); - } + // Flatten params (ModelParams anyOf: OpenAIModelParams | AnthropicModelParams | ...) + // into the result map via Jackson, mirroring the old behaviour of merging + // promptData.options.params into the top level. + ModelParams modelParams = opts.getParams(); + if (modelParams != null && modelParams.getActualInstance() != null) { + @SuppressWarnings("unchecked") + Map paramsMap = + MAPPER.convertValue(modelParams.getActualInstance(), Map.class); + result.putAll(paramsMap); } + return applyDefaults(result); + } + + private Map applyDefaults(Map base) { + Map result = new HashMap<>(base); + for (Map.Entry entry : defaults.entrySet()) { + result.putIfAbsent(entry.getKey(), entry.getValue()); + } return result; } + /** + * Extract a String from an anyOf [String, List<ChatCompletionContentPart*>] content + * instance. Only the String variant is supported for Mustache rendering; list-form content + * (e.g. vision messages) is returned as-is via toString. + */ + private String extractStringContent(@Nullable Object contentInstance) { + if (contentInstance instanceof String s) { + return s; + } + return contentInstance != null ? contentInstance.toString() : null; + } + private String renderTemplate(String template, Map parameters) { try { DefaultMustacheFactory factory = new DefaultMustacheFactory(); @@ -92,11 +164,10 @@ private String renderTemplate(String template, Map parameters) { writer.flush(); return writer.toString(); } catch (MustacheException e) { - // If the template is malformed, just return it as-is return template; } catch (Exception e) { - if (e instanceof RuntimeException) { - throw (RuntimeException) e; + if (e instanceof RuntimeException re) { + throw re; } throw new RuntimeException("Failed to render template", e); } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPromptLoader.java b/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPromptLoader.java index 00bdc1c8..52c6ded5 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPromptLoader.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/prompt/BraintrustPromptLoader.java @@ -1,48 +1,75 @@ package dev.braintrust.prompt; -import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.api.BraintrustOpenApiClient; import dev.braintrust.config.BraintrustConfig; +import dev.braintrust.openapi.api.PromptsApi; +import dev.braintrust.openapi.model.PromptDataNullish; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.Builder; -/** Load LLM objects from the Braintrust API */ +/** Load LLM prompts from the Braintrust API */ public class BraintrustPromptLoader { private final BraintrustConfig config; - private final BraintrustApiClient client; + private final PromptsApi promptsApi; - private BraintrustPromptLoader(BraintrustConfig config, BraintrustApiClient client) { + private BraintrustPromptLoader(BraintrustConfig config, BraintrustOpenApiClient apiClient) { this.config = config; - this.client = client; + this.promptsApi = new PromptsApi(apiClient); } - public static BraintrustPromptLoader of(BraintrustConfig config, BraintrustApiClient client) { - return new BraintrustPromptLoader(config, client); + public static BraintrustPromptLoader of( + BraintrustConfig config, BraintrustOpenApiClient apiClient) { + return new BraintrustPromptLoader(config, apiClient); } public BraintrustPrompt load(String promptSlug) { - PromptLoadRequest request = PromptLoadRequest.builder().promptSlug(promptSlug).build(); - return load(request); + return load(PromptLoadRequest.builder().promptSlug(promptSlug).build()); } - public BraintrustPrompt load(PromptLoadRequest promptLoadRequest) { - var projectName = promptLoadRequest.projectName; - if (null == projectName) { - // TODO: fall back to project ID if appropriate - projectName = config.defaultProjectName().orElseThrow(); + public BraintrustPrompt load(PromptLoadRequest request) { + var projectName = + request.projectName != null + ? request.projectName + : config.defaultProjectName().orElseThrow(); + + var response = + promptsApi.getPrompt( + null, // limit + null, // startingAfter + null, // endingBefore + null, // ids + null, // promptName + projectName, // projectName + null, // projectId + request.promptSlug, // slug + request.version, // version + null, // environment + null // orgName + ); + + List objects = response.getObjects(); + if (objects == null || objects.isEmpty()) { + throw new RuntimeException("Prompt not found: " + request.promptSlug); + } + if (objects.size() > 1) { + throw new RuntimeException( + "Multiple prompts found for slug: " + + request.promptSlug + + ", projectName: " + + projectName); + } + + var prompt = (dev.braintrust.openapi.model.Prompt) objects.get(0); + PromptDataNullish promptData = prompt.getPromptData(); + if (promptData == null) { + throw new RuntimeException("prompt_data missing for prompt: " + request.promptSlug); } - // Request the prompt from the Braintrust API - var promptOpt = - client.getPrompt( - projectName, promptLoadRequest.promptSlug, promptLoadRequest.version); - var prompt = - promptOpt.orElseThrow( - () -> - new RuntimeException( - "Prompt not found: " + promptLoadRequest.promptSlug)); - return new BraintrustPrompt(prompt, promptLoadRequest.defaults); + + return new BraintrustPrompt(promptData, request.defaults); } @Builder @@ -67,14 +94,10 @@ private static Map keyValueListToMap(T... keyValueList) { throw new IllegalArgumentException( "keyValueList must contain an even number of elements (key-value pairs)"); } - Map map = new LinkedHashMap<>(); for (int i = 0; i < keyValueList.length; i += 2) { - T key = keyValueList[i]; - T value = keyValueList[i + 1]; - map.put(key, value); + map.put(keyValueList[i], keyValueList[i + 1]); } - return Map.copyOf(map); } } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java b/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java index 54bd08fd..0ad8f999 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java @@ -5,7 +5,7 @@ import static org.junit.jupiter.api.Assertions.*; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import dev.braintrust.api.BraintrustApiClient; +import dev.braintrust.api.BraintrustOpenApiClient; import dev.braintrust.config.BraintrustConfig; import java.util.*; import org.junit.jupiter.api.BeforeEach; @@ -18,21 +18,20 @@ public class DatasetBrainstoreImplTest { static WireMockExtension wireMock = WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); - private BraintrustApiClient apiClient; + private BraintrustOpenApiClient apiClient; private String datasetId; @BeforeEach void beforeEach() { wireMock.resetAll(); - datasetId = "test-dataset-123"; + datasetId = "00000000-0000-0000-0000-000000000123"; - // Create API client pointing to WireMock server var config = BraintrustConfig.builder() .apiKey("test-api-key") .apiUrl("http://localhost:" + wireMock.getPort()) .build(); - apiClient = BraintrustApiClient.of(config); + apiClient = BraintrustOpenApiClient.of(config); } @Test @@ -51,26 +50,27 @@ void testFetchAll() { "events": [ { "object_type": "dataset", - "dataset_id": "test-dataset-123", + "dataset_id": "%s", "id": "123-1", - "created": "sometimestamp", + "created": "2024-01-01T00:00:00Z", "_xact_id": "1", "input": "Question 1", "expected": "Answer 1" }, { "object_type": "dataset", - "dataset_id": "test-dataset-123", + "dataset_id": "%s", "id": "123-2", "_xact_id": "1", - "created": "sometimestamp", + "created": "2024-01-01T00:00:00Z", "input": "Question 2", "expected": "Answer 2" } ], "cursor": "next-page-token" } - """))); + """ + .formatted(datasetId, datasetId)))); // Mock the second batch without a cursor (last page) wireMock.stubFor( @@ -86,69 +86,38 @@ void testFetchAll() { "events": [ { "object_type": "dataset", - "dataset_id": "test-dataset-123", + "dataset_id": "%s", "id": "123-3", "_xact_id": "1", - "created": "sometimestamp", + "created": "2024-01-01T00:00:00Z", "input": "Question 3", "expected": "Answer 3" } ], "cursor": null } - """))); + """ + .formatted(datasetId)))); - // Create dataset with smaller batch size DatasetBrainstoreImpl dataset = new DatasetBrainstoreImpl<>(apiClient, datasetId, "test-version", 2); List> cases = new ArrayList<>(); dataset.forEach(cases::add); - // Verify we got all 3 cases assertEquals(3, cases.size()); - List tags = List.of(); - Map metadata = Map.of(); - assertEquals( - new DatasetCase<>( - "Question 1", - "Answer 1", - tags, - metadata, - Optional.of( - new dev.braintrust.Origin( - "dataset", datasetId, "123-1", "1", "sometimestamp"))), - cases.get(0)); + assertEquals("Question 1", cases.get(0).input()); + assertEquals("Answer 1", cases.get(0).expected()); assertEquals("Question 2", cases.get(1).input()); - assertEquals( - new DatasetCase<>( - "Question 2", - "Answer 2", - tags, - metadata, - Optional.of( - new dev.braintrust.Origin( - "dataset", datasetId, "123-2", "1", "sometimestamp"))), - cases.get(1)); + assertEquals("Answer 2", cases.get(1).expected()); assertEquals("Question 3", cases.get(2).input()); - assertEquals( - new DatasetCase<>( - "Question 3", - "Answer 3", - tags, - metadata, - Optional.of( - new dev.braintrust.Origin( - "dataset", datasetId, "123-3", "1", "sometimestamp"))), - cases.get(2)); + assertEquals("Answer 3", cases.get(2).expected()); - // Verify the API was called twice (once for each batch) wireMock.verify(2, postRequestedFor(urlEqualTo("/v1/dataset/" + datasetId + "/fetch"))); } @Test void testEmptyDataset() { - // Mock empty dataset wireMock.stubFor( post(urlEqualTo("/v1/dataset/" + datasetId + "/fetch")) .willReturn( @@ -169,10 +138,7 @@ void testEmptyDataset() { List> cases = new ArrayList<>(); dataset.forEach(cases::add); - // Verify we got no cases assertEquals(0, cases.size()); - - // Verify the API was called once wireMock.verify(1, postRequestedFor(urlEqualTo("/v1/dataset/" + datasetId + "/fetch"))); } @@ -181,8 +147,9 @@ void testFetchWithPinnedVersion() { String projectName = "test-project"; String datasetName = "test-dataset"; String pinnedVersion = "12345"; + String datasetUuid = "00000000-0000-0000-0000-000000000789"; - // Mock the query endpoint + // Mock the dataset lookup wireMock.stubFor( get(urlPathEqualTo("/v1/dataset")) .withQueryParam("project_name", equalTo(projectName)) @@ -196,24 +163,20 @@ void testFetchWithPinnedVersion() { { "objects": [ { - "object_type": "dataset", - "dataset_id": "test-dataset-123", - "id": "dataset-789", - "project_id": "proj-456", + "id": "%s", + "project_id": "00000000-0000-0000-0000-000000000456", "name": "test-dataset", - "description": "Test dataset", "_xact_id": "12345", - "input": "test input", - "expected": "test output", - "created": "sometimestamp" + "created": "2024-01-01T00:00:00Z" } ] } - """))); + """ + .formatted(datasetUuid)))); - // Mock the fetch endpoint - only succeeds if version is passed correctly + // Mock the fetch endpoint with version wireMock.stubFor( - post(urlEqualTo("/v1/dataset/dataset-789/fetch")) + post(urlEqualTo("/v1/dataset/" + datasetUuid + "/fetch")) .withRequestBody(matchingJsonPath("$.version", equalTo(pinnedVersion))) .willReturn( aResponse() @@ -224,40 +187,37 @@ void testFetchWithPinnedVersion() { { "events": [ { - "object_type": "dataset", - "dataset_id": "test-dataset-123", + "dataset_id": "%s", "id": "some-row-id", "input": "test input", "expected": "test output", "metadata": {}, "tags": [], "_xact_id": "12346", - "created": "sometimestamp" + "created": "2024-01-01T00:00:00Z" } ], "cursor": null } - """))); + """ + .formatted(datasetUuid)))); Dataset dataset = Dataset.fetchFromBraintrust(apiClient, projectName, datasetName, pinnedVersion); - assertEquals("dataset-789", dataset.id()); + assertEquals(datasetUuid, dataset.id()); assertEquals(Optional.of(pinnedVersion), dataset.version()); - // Open cursor and fetch data to trigger the API call with version List> cases = new ArrayList<>(); dataset.forEach(cases::add); - // Verify we got the expected case assertEquals(1, cases.size()); assertEquals("test input", cases.get(0).input()); assertEquals("test output", cases.get(0).expected()); - // Verify the fetch endpoint was called with the version wireMock.verify( 1, - postRequestedFor(urlEqualTo("/v1/dataset/dataset-789/fetch")) + postRequestedFor(urlEqualTo("/v1/dataset/" + datasetUuid + "/fetch")) .withRequestBody(matchingJsonPath("$.version", equalTo(pinnedVersion)))); } @@ -266,7 +226,6 @@ void testFetchFromBraintrustNotFound() { String projectName = "test-project"; String datasetName = "nonexistent"; - // Mock empty response wireMock.stubFor( get(urlPathEqualTo("/v1/dataset")) .withQueryParam("project_name", equalTo(projectName)) diff --git a/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptLoaderTest.java b/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptLoaderTest.java index 96fa790f..c896c4a3 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptLoaderTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptLoaderTest.java @@ -2,181 +2,97 @@ import static org.junit.jupiter.api.Assertions.*; -import dev.braintrust.api.BraintrustApiClient; -import dev.braintrust.config.BraintrustConfig; +import dev.braintrust.TestHarness; import java.util.List; import java.util.Map; -import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class BraintrustPromptLoaderTest { + private TestHarness testHarness; + + @BeforeEach + void beforeEach() { + testHarness = TestHarness.setup(); + } @Test void testLoadPromptBySlug() { - // Create test data - BraintrustApiClient.OrganizationInfo orgInfo = - new BraintrustApiClient.OrganizationInfo("org-123", "Test Org"); - BraintrustApiClient.Project project = - new BraintrustApiClient.Project( - "proj-456", "test-project", "org-123", "2025-01-01", "2025-01-01"); - BraintrustApiClient.OrganizationAndProjectInfo orgAndProject = - new BraintrustApiClient.OrganizationAndProjectInfo(orgInfo, project); - - // Create a test prompt - BraintrustApiClient.Prompt testPrompt = createTestPrompt(project.id()); - - // Create in-memory API client with the test prompt - BraintrustApiClient apiClient = - new BraintrustApiClient.InMemoryImpl(List.of(orgAndProject), List.of(testPrompt)); - - // Create config - BraintrustConfig config = - BraintrustConfig.of( - "BRAINTRUST_API_KEY", - "doesntmatter", - "BRAINTRUST_DEFAULT_PROJECT_NAME", - "test-project"); - - // Create loader - BraintrustPromptLoader loader = BraintrustPromptLoader.of(config, apiClient); - - // Load the prompt - BraintrustPrompt prompt = loader.load("kind-greeter"); - - // Verify the prompt was loaded correctly + BraintrustPromptLoader loader = testHarness.braintrust().promptLoader(); + + BraintrustPrompt prompt = loader.load("kind-greeter-0bd1"); + assertNotNull(prompt); - // Test rendering + // Render with the template variable the prompt expects Map parameters = Map.of("name", "Bob"); List> renderedMessages = prompt.renderMessages(parameters); - assertEquals(2, renderedMessages.size()); - assertEquals("What's up my friend? My name is Bob", renderedMessages.get(1).get("content")); + assertFalse(renderedMessages.isEmpty()); + // The user message should contain the rendered name + boolean nameRendered = + renderedMessages.stream() + .anyMatch( + msg -> { + Object content = msg.get("content"); + return content instanceof String + && ((String) content).contains("Bob"); + }); + assertTrue(nameRendered, "Expected rendered messages to contain the substituted name"); + } + + @Test + void testLoadPromptBySlugWithVersion() { + BraintrustPromptLoader loader = testHarness.braintrust().promptLoader(); + + BraintrustPrompt prompt = + loader.load( + BraintrustPromptLoader.PromptLoadRequest.builder() + .promptSlug("kind-greeter-0bd1") + .version("27fdcc80d22c7ec5") + .build()); + + assertNotNull(prompt); + + List> messages = prompt.renderMessages(Map.of("name", "Bob")); + assertEquals("system", messages.get(0).get("role")); + assertEquals("this is an old version", messages.get(0).get("content")); } @Test void testLoadPromptWithDefaults() { - // Create test data - BraintrustApiClient.OrganizationInfo orgInfo = - new BraintrustApiClient.OrganizationInfo("org-123", "Test Org"); - BraintrustApiClient.Project project = - new BraintrustApiClient.Project( - "proj-456", "test-project", "org-123", "2025-01-01", "2025-01-01"); - BraintrustApiClient.OrganizationAndProjectInfo orgAndProject = - new BraintrustApiClient.OrganizationAndProjectInfo(orgInfo, project); - - // Create a test prompt - BraintrustApiClient.Prompt testPrompt = createTestPrompt(project.id()); - - // Create in-memory API client with the test prompt - BraintrustApiClient apiClient = - new BraintrustApiClient.InMemoryImpl(List.of(orgAndProject), List.of(testPrompt)); - - // Create config - BraintrustConfig config = - BraintrustConfig.of( - "BRAINTRUST_API_KEY", - "doesntmatter", - "BRAINTRUST_DEFAULT_PROJECT_NAME", - "test-project"); - - // Create loader - BraintrustPromptLoader loader = BraintrustPromptLoader.of(config, apiClient); - - // Load the prompt with defaults + BraintrustPromptLoader loader = testHarness.braintrust().promptLoader(); + BraintrustPrompt prompt = loader.load( BraintrustPromptLoader.PromptLoadRequest.builder() - .promptSlug("kind-greeter") + .promptSlug("kind-greeter-0bd1") .defaults("max_tokens", "2000", "top_p", "0.95") .build()); + assertNotNull(prompt); + // Verify defaults are applied Map options = prompt.getOptions(); assertEquals("2000", options.get("max_tokens")); assertEquals("0.95", options.get("top_p")); - // Verify original options are preserved - assertEquals("gpt-4o-mini", options.get("model")); - assertEquals(0, options.get("temperature")); + // Verify existing options from the real prompt are preserved (not clobbered by defaults) + assertTrue(options.containsKey("model"), "Expected 'model' option to be present"); } @Test void testLoadPromptWithProjectName() { - // Create test data - BraintrustApiClient.OrganizationInfo orgInfo = - new BraintrustApiClient.OrganizationInfo("org-123", "Test Org"); - BraintrustApiClient.Project project = - new BraintrustApiClient.Project( - "proj-456", "my-project", "org-123", "2025-01-01", "2025-01-01"); - BraintrustApiClient.OrganizationAndProjectInfo orgAndProject = - new BraintrustApiClient.OrganizationAndProjectInfo(orgInfo, project); + BraintrustPromptLoader loader = testHarness.braintrust().promptLoader(); - // Create a test prompt - BraintrustApiClient.Prompt testPrompt = createTestPrompt(project.id()); - - // Create in-memory API client with the test prompt - BraintrustApiClient apiClient = - new BraintrustApiClient.InMemoryImpl(List.of(orgAndProject), List.of(testPrompt)); - - // Create config without default project name - BraintrustConfig config = BraintrustConfig.of("BRAINTRUST_API_KEY", "test-key"); - - // Create loader - BraintrustPromptLoader loader = BraintrustPromptLoader.of(config, apiClient); - - // Load the prompt with explicit project name + // Load the prompt with an explicit project name (not relying on the config default) BraintrustPrompt prompt = loader.load( BraintrustPromptLoader.PromptLoadRequest.builder() - .promptSlug("kind-greeter") - .projectName("my-project") + .promptSlug("kind-greeter-0bd1") + .projectName(TestHarness.defaultProjectName()) .build()); - // Verify the prompt was loaded correctly assertNotNull(prompt); } - - private BraintrustApiClient.Prompt createTestPrompt(String projectId) { - // Create the prompt data structure matching the example JSON - Map messages = - Map.of( - "messages", - List.of( - Map.of( - "role", "system", - "content", - "You are a kind chatbot who briefly greets people"), - Map.of( - "role", "user", - "content", "What's up my friend? My name is {{name}}"))); - - Map options = - Map.of( - "model", "gpt-4o-mini", - "params", - Map.of( - "use_cache", - true, - "temperature", - 0, - "response_format", - Map.of("type", "text")), - "position", "0|hzzzzz:"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - return new BraintrustApiClient.Prompt( - "e2a4fb20-e97e-4e8a-be07-b226d55047b2", - projectId, - "e8d257dd-944c-479a-9916-40a9fa09f120", - "kind-greeter", - "kind-greeter", - Optional.of("A very good boi"), - "2025-10-21T21:35:18.287Z", - promptData, - Optional.empty(), - Optional.empty()); - } } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptTest.java b/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptTest.java index 17a61adc..c1451191 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/prompt/BraintrustPromptTest.java @@ -2,121 +2,130 @@ import static org.junit.jupiter.api.Assertions.*; -import dev.braintrust.api.BraintrustApiClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.braintrust.openapi.JSON; +import dev.braintrust.openapi.model.PromptDataNullish; import java.util.List; import java.util.Map; -import java.util.Optional; import org.junit.jupiter.api.Test; public class BraintrustPromptTest { + private static final ObjectMapper MAPPER = new JSON().getMapper(); + + /** + * Build a PromptDataNullish from a plain Java map that mirrors the JSON structure the + * Braintrust API returns. This lets test cases stay readable without constructing the full + * generated type hierarchy by hand. + */ + private static PromptDataNullish promptData( + Map prompt, Map options) { + Map raw = Map.of("prompt", prompt, "options", options); + return MAPPER.convertValue(raw, PromptDataNullish.class); + } + + private static PromptDataNullish createTestPromptData() { + Map prompt = + Map.of( + "type", + "chat", + "messages", + List.of( + Map.of( + "role", + "system", + "content", + "You are a kind chatbot who briefly greets people"), + Map.of( + "role", "user", + "content", "What's up my friend? My name is {{name}}"))); + + Map options = + Map.of( + "model", + "gpt-4o-mini", + "params", + Map.of( + "use_cache", + true, + "temperature", + 0, + "response_format", + Map.of("type", "text")), + "position", + "0|hzzzzz:"); + + return promptData(prompt, options); + } + @Test void testGetOptionsWithDefaults() { - BraintrustApiClient.Prompt promptObject = createTestPrompt(); - - // Create a prompt with defaults Map defaults = Map.of( "max_tokens", "1000", - "temperature", - "0.7", // This should be ignored as temperature is already set to 0 + "temperature", "0.7", // should be ignored — temperature is already set to 0 "top_p", "0.9"); - BraintrustPrompt prompt = new BraintrustPrompt(promptObject, defaults); + BraintrustPrompt prompt = new BraintrustPrompt(createTestPromptData(), defaults); Map options = prompt.getOptions(); - // Verify that defaults are applied only when not already set - assertEquals("1000", options.get("max_tokens")); // Applied from defaults - assertEquals("0.9", options.get("top_p")); // Applied from defaults - assertEquals( - 0, - options.get( - "temperature")); // NOT overridden by defaults (original value preserved) + assertEquals("1000", options.get("max_tokens")); // applied from defaults + assertEquals("0.9", options.get("top_p")); // applied from defaults + // temperature already present — default must not override it + assertNotNull(options.get("temperature")); + assertNotEquals("0.7", options.get("temperature").toString()); - // Verify original options are still present assertEquals("gpt-4o-mini", options.get("model")); assertEquals(true, options.get("use_cache")); } @Test void testRenderMessagesWithMalformedMustache() { - // Create a prompt with malformed mustache syntax - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( + Map.of("role", "system", "content", "You are a helpful assistant"), Map.of( - "role", "system", - "content", "You are a helpful assistant"), - Map.of( - "role", "user", - "content", "Hello {{ whatever. This should not match."))); - - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); - - // Render with empty parameters - malformed mustache should be ignored - Map parameters = Map.of(); - List> renderedMessages = prompt.renderMessages(parameters); - - // Verify the malformed mustache is left as-is (not treated as a parameter) - assertEquals(2, renderedMessages.size()); - assertEquals( - "Hello {{ whatever. This should not match.", - renderedMessages.get(1).get("content")); + "role", + "user", + "content", + "Hello {{ whatever. This should not match."))); + + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); + + List> rendered = braintrustPrompt.renderMessages(Map.of()); + + assertEquals(2, rendered.size()); + assertEquals("Hello {{ whatever. This should not match.", rendered.get(1).get("content")); } @Test void testRenderMessagesWithParameters() { - // Create a test prompt object - BraintrustApiClient.Prompt promptObject = createTestPrompt(); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); + BraintrustPrompt prompt = new BraintrustPrompt(createTestPromptData()); - // Render messages with parameters - Map parameters = Map.of("name", "Alice"); - List> renderedMessages = prompt.renderMessages(parameters); + List> rendered = prompt.renderMessages(Map.of("name", "Alice")); - // Verify the messages were rendered correctly - assertEquals(2, renderedMessages.size()); - - Map systemMessage = renderedMessages.get(0); - assertEquals("system", systemMessage.get("role")); + assertEquals(2, rendered.size()); + assertEquals("system", rendered.get(0).get("role")); assertEquals( - "You are a kind chatbot who briefly greets people", systemMessage.get("content")); - - Map userMessage = renderedMessages.get(1); - assertEquals("user", userMessage.get("role")); - assertEquals("What's up my friend? My name is Alice", userMessage.get("content")); + "You are a kind chatbot who briefly greets people", rendered.get(0).get("content")); + assertEquals("user", rendered.get(1).get("role")); + assertEquals("What's up my friend? My name is Alice", rendered.get(1).get("content")); } @Test void testRenderMessagesWithList() { - // Create a prompt that uses Mustache list iteration - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( - Map.of( - "role", "system", - "content", "You are a helpful assistant"), + Map.of("role", "system", "content", "You are a helpful assistant"), Map.of( "role", "user", @@ -125,27 +134,9 @@ void testRenderMessagesWithList() { + "{{#items}}- {{name}}: {{description}}\n" + "{{/items}}"))); - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); - // Render with list parameters Map parameters = Map.of( "items", @@ -154,22 +145,23 @@ void testRenderMessagesWithList() { Map.of("name", "Banana", "description", "A yellow fruit"), Map.of("name", "Cherry", "description", "A small red fruit"))); - List> renderedMessages = prompt.renderMessages(parameters); + List> rendered = braintrustPrompt.renderMessages(parameters); - assertEquals(2, renderedMessages.size()); - String expectedContent = + assertEquals(2, rendered.size()); + assertEquals( "Here are the items:\n" + "- Apple: A red fruit\n" + "- Banana: A yellow fruit\n" - + "- Cherry: A small red fruit\n"; - assertEquals(expectedContent, renderedMessages.get(1).get("content")); + + "- Cherry: A small red fruit\n", + rendered.get(1).get("content")); } @Test void testRenderMessagesWithEmptyList() { - // Create a prompt that uses Mustache list iteration - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( Map.of( @@ -179,44 +171,25 @@ void testRenderMessagesWithEmptyList() { "Items: {{#items}}{{name}} {{/items}}{{^items}}No items" + " found{{/items}}"))); - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); + List> rendered = + braintrustPrompt.renderMessages(Map.of("items", List.of())); - // Render with empty list - Map parameters = Map.of("items", List.of()); - List> renderedMessages = prompt.renderMessages(parameters); - - assertEquals(1, renderedMessages.size()); - assertEquals("Items: No items found", renderedMessages.get(0).get("content")); + assertEquals(1, rendered.size()); + assertEquals("Items: No items found", rendered.get(0).get("content")); } @Test void testRenderMessagesWithConditional() { - // Create a prompt that uses Mustache conditionals - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( - Map.of( - "role", "system", - "content", "You are a helpful assistant"), + Map.of("role", "system", "content", "You are a helpful assistant"), Map.of( "role", "user", @@ -225,43 +198,25 @@ void testRenderMessagesWithConditional() { + " privileges.{{/isAdmin}}{{^isAdmin}} You are a" + " regular user.{{/isAdmin}}"))); - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); - - // Test with admin user - Map adminParameters = Map.of("name", "Alice", "isAdmin", true); - List> adminMessages = prompt.renderMessages(adminParameters); + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); + + List> adminMessages = + braintrustPrompt.renderMessages(Map.of("name", "Alice", "isAdmin", true)); assertEquals( "Hello Alice! You have admin privileges.", adminMessages.get(1).get("content")); - // Test with regular user - Map regularParameters = Map.of("name", "Bob", "isAdmin", false); - List> regularMessages = prompt.renderMessages(regularParameters); + List> regularMessages = + braintrustPrompt.renderMessages(Map.of("name", "Bob", "isAdmin", false)); assertEquals("Hello Bob! You are a regular user.", regularMessages.get(1).get("content")); } @Test void testRenderMessagesWithNestedObjects() { - // Create a prompt that uses nested object properties - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( Map.of( @@ -272,27 +227,9 @@ void testRenderMessagesWithNestedObjects() { + "Email: {{user.contact.email}}\n" + "Phone: {{user.contact.phone}}"))); - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); - // Render with nested object Map parameters = Map.of( "user", @@ -304,18 +241,20 @@ void testRenderMessagesWithNestedObjects() { "contact", Map.of("email", "john@example.com", "phone", "555-1234"))); - List> renderedMessages = prompt.renderMessages(parameters); + List> rendered = braintrustPrompt.renderMessages(parameters); - assertEquals(1, renderedMessages.size()); - String expectedContent = "User: John Doe\nEmail: john@example.com\nPhone: " + "555-1234"; - assertEquals(expectedContent, renderedMessages.get(0).get("content")); + assertEquals(1, rendered.size()); + assertEquals( + "User: John Doe\nEmail: john@example.com\nPhone: 555-1234", + rendered.get(0).get("content")); } @Test void testRenderMessagesWithInvertedSection() { - // Create a prompt that uses inverted sections (renders when value is false/empty) - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( Map.of( @@ -326,43 +265,31 @@ void testRenderMessagesWithInvertedSection() { + " {{errorMessage}}{{/hasError}}{{^hasError}}All" + " systems operational{{/hasError}}"))); - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); - - // Test without error - Map noErrorParams = Map.of("hasError", false); - List> noErrorMessages = prompt.renderMessages(noErrorParams); - assertEquals("All systems operational", noErrorMessages.get(0).get("content")); - - // Test with error - Map errorParams = - Map.of("hasError", true, "errorMessage", "Database connection failed"); - List> errorMessages = prompt.renderMessages(errorParams); - assertEquals("Error: Database connection failed", errorMessages.get(0).get("content")); + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); + + assertEquals( + "All systems operational", + braintrustPrompt.renderMessages(Map.of("hasError", false)).get(0).get("content")); + assertEquals( + "Error: Database connection failed", + braintrustPrompt + .renderMessages( + Map.of( + "hasError", + true, + "errorMessage", + "Database connection failed")) + .get(0) + .get("content")); } @Test void testRenderMessagesWithComplexTypes() { - // Test that non-string types are properly rendered - Map messages = + Map prompt = Map.of( + "type", + "chat", "messages", List.of( Map.of( @@ -373,76 +300,14 @@ void testRenderMessagesWithComplexTypes() { + "Price: ${{price}}\n" + "Enabled: {{enabled}}"))); - Map options = Map.of("model", "gpt-4o-mini"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - BraintrustApiClient.Prompt promptObject = - new BraintrustApiClient.Prompt( - "test-id", - "proj-id", - "org-id", - "test-prompt", - "test-slug", - Optional.empty(), - "2025-01-01T00:00:00Z", - promptData, - Optional.empty(), - Optional.empty()); - - BraintrustPrompt prompt = new BraintrustPrompt(promptObject); + BraintrustPrompt braintrustPrompt = + new BraintrustPrompt(promptData(prompt, Map.of("model", "gpt-4o-mini"))); - // Test with various data types - Map parameters = Map.of("count", 42, "price", 19.99, "enabled", true); + List> rendered = + braintrustPrompt.renderMessages( + Map.of("count", 42, "price", 19.99, "enabled", true)); - List> renderedMessages = prompt.renderMessages(parameters); - - assertEquals(1, renderedMessages.size()); - assertEquals( - "Count: 42\nPrice: $19.99\nEnabled: true", renderedMessages.get(0).get("content")); - } - - private BraintrustApiClient.Prompt createTestPrompt() { - // Create the prompt data structure matching the example JSON - Map messages = - Map.of( - "messages", - List.of( - Map.of( - "role", "system", - "content", - "You are a kind chatbot who briefly greets people"), - Map.of( - "role", "user", - "content", "What's up my friend? My name is {{name}}"))); - - Map options = - Map.of( - "model", "gpt-4o-mini", - "params", - Map.of( - "use_cache", - true, - "temperature", - 0, - "response_format", - Map.of("type", "text")), - "position", "0|hzzzzz:"); - - BraintrustApiClient.PromptData promptData = - new BraintrustApiClient.PromptData(messages, options); - - return new BraintrustApiClient.Prompt( - "e2a4fb20-e97e-4e8a-be07-b226d55047b2", - "e8d257dd-944c-479a-9916-40a9fa09f120", - "5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e", - "kind-greeter", - "kind-greeter-69d2", - Optional.of("A very good boi"), - "2025-10-21T21:35:18.287Z", - promptData, - Optional.empty(), - Optional.empty()); + assertEquals(1, rendered.size()); + assertEquals("Count: 42\nPrice: $19.99\nEnabled: true", rendered.get(0).get("content")); } } diff --git a/gradle.properties b/gradle.properties index 21de6bcd..be3e95a7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,9 @@ org.gradle.warning.mode=summary # braintrust-spec git ref (SHA or tag) used by btx tests braintrustSpecRef=v0.0.2 +# braintrust-openapi commit SHA used by braintrust-api +braintrustOpenApiRef=64b79cb9122f50a74eac98ea86c3ec1858c0cdd1 + # Let Gradle locate local JDKs and download one if needed org.gradle.java.installations.auto-detect=true org.gradle.java.installations.auto-download=true diff --git a/scripts/openapi-fetch.sh b/scripts/openapi-fetch.sh new file mode 100755 index 00000000..120c865f --- /dev/null +++ b/scripts/openapi-fetch.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Fetches openapi/spec.yaml from a pinned commit (SHA or branch) of +# https://github.com/braintrustdata/braintrust-openapi +# +# Usage: openapi-fetch.sh +set -euo pipefail + +REPO="braintrustdata/braintrust-openapi" +REF="${1:?Usage: $0 }" +OUTDIR="${2:-.}" + +mkdir -p "$OUTDIR" + +URL="https://raw.githubusercontent.com/${REPO}/${REF}/openapi/spec.yaml" + +echo "Fetching braintrust-openapi@${REF} -> ${OUTDIR}/spec.yaml" +curl -sfL "$URL" -o "${OUTDIR}/spec.yaml" || { + echo "Error: failed to download spec.yaml from ${URL}" >&2 + exit 1 +} +echo "Done." diff --git a/settings.gradle b/settings.gradle index 67e00999..ea5c0e07 100644 --- a/settings.gradle +++ b/settings.gradle @@ -25,4 +25,5 @@ include 'braintrust-java-agent:smoke-test:spring-boot' include 'braintrust-java-agent:smoke-test:tomcat' include 'braintrust-java-agent:smoke-test:wildfly' include 'btx' +include 'braintrust-api' include 'braintrust-otel-extension' diff --git a/test-harness/src/testFixtures/java/dev/braintrust/TestHarness.java b/test-harness/src/testFixtures/java/dev/braintrust/TestHarness.java index 5540c06f..d16e1279 100644 --- a/test-harness/src/testFixtures/java/dev/braintrust/TestHarness.java +++ b/test-harness/src/testFixtures/java/dev/braintrust/TestHarness.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; -import dev.braintrust.api.BraintrustApiClient; import dev.braintrust.config.BraintrustConfig; import dev.braintrust.trace.UnitTestShutdownHook; import io.opentelemetry.api.GlobalOpenTelemetry; @@ -201,23 +200,6 @@ public List awaitExportedSpans(int minSpanCount) { return spanExporter.getFinishedSpanItems(minSpanCount); } - private static BraintrustApiClient.InMemoryImpl createApiClient() { - var orgInfo = - new dev.braintrust.api.BraintrustApiClient.OrganizationInfo( - defaultOrgId, defaultOrgName); - var project = - new dev.braintrust.api.BraintrustApiClient.Project( - defaultProjectId, - defaultProjectName, - "unit_test_org_123", - "2023-01-01T00:00:00Z", - "2023-01-01T00:00:00Z"); - var orgAndProjectInfo = - new dev.braintrust.api.BraintrustApiClient.OrganizationAndProjectInfo( - orgInfo, project); - return new dev.braintrust.api.BraintrustApiClient.InMemoryImpl(orgAndProjectInfo); - } - public static VCR.VcrMode getVcrMode() { return vcr.getMode(); } diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json new file mode 100644 index 00000000..9a18a482 --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json @@ -0,0 +1 @@ +{"org_info":[{"id":"5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e","name":"braintrustdata.com","api_url":"https://staging-api.braintrust.dev","git_metadata":{"fields":["commit","branch","tag","author_name","author_email","commit_message","commit_time","dirty"],"collect":"some"},"is_universal_api":true,"proxy_url":"https://staging-api.braintrust.dev","realtime_url":"wss://realtime.braintrustapi.com"}]} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json new file mode 100644 index 00000000..0606dcda --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json @@ -0,0 +1 @@ +{"objects":[{"id":"6ae68365-7620-4630-921b-bac416634fc8","org_id":"5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e","name":"java-unit-test","description":null,"created":"2026-01-21T01:32:52.137Z","deleted_at":null,"user_id":"a5ca7f9c-bf20-40c4-a82b-5c992f6a38f5","settings":{"remote_eval_sources":[{"url":"http://localhost:8301","name":"java-devserver","description":null}]}}]} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json new file mode 100644 index 00000000..4f2fad3c --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json @@ -0,0 +1 @@ +{"objects":[{"_xact_id":"1000197048220868163","created":"2026-04-23T20:44:09.171Z","description":null,"function_type":null,"id":"07961a58-38d9-490d-97c0-6a6ecbb9fa0b","log_id":"p","metadata":null,"name":"kind-greeter","org_id":"5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e","project_id":"6ae68365-7620-4630-921b-bac416634fc8","prompt_data":{"prompt":{"type":"chat","messages":[{"role":"system","content":"You are a nice chatbot who greets people. Personalize your greeting if you know who the person is (for example, give them a specific compliment)"},{"role":"user","content":"Hello! My name is {{name}}"}]},"options":{"model":"gpt-5.4-pro-2026-03-05","params":{"use_cache":true,"temperature":0},"position":"0|hzzzzz:"}},"slug":"kind-greeter-0bd1","tags":null,"function_data":{"type":"prompt"}}]} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json new file mode 100644 index 00000000..08706d48 --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json @@ -0,0 +1 @@ +{"objects":[{"_xact_id":"1000197048218409069","created":"2026-04-23T20:43:31.242Z","description":null,"function_type":null,"id":"07961a58-38d9-490d-97c0-6a6ecbb9fa0b","log_id":"p","metadata":null,"name":"kind-greeter","org_id":"5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e","project_id":"6ae68365-7620-4630-921b-bac416634fc8","prompt_data":{"prompt":{"type":"chat","messages":[{"role":"system","content":"this is an old version"},{"role":"user","content":"Hello! My name is {{name}}"}]},"options":{"model":"gpt-5.4-pro-2026-03-05","params":{"use_cache":true,"temperature":0},"position":"0|hzzzzz:"}},"slug":"kind-greeter-0bd1","tags":null,"function_data":{"type":"prompt"}}]} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json new file mode 100644 index 00000000..4f2fad3c --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json @@ -0,0 +1 @@ +{"objects":[{"_xact_id":"1000197048220868163","created":"2026-04-23T20:44:09.171Z","description":null,"function_type":null,"id":"07961a58-38d9-490d-97c0-6a6ecbb9fa0b","log_id":"p","metadata":null,"name":"kind-greeter","org_id":"5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e","project_id":"6ae68365-7620-4630-921b-bac416634fc8","prompt_data":{"prompt":{"type":"chat","messages":[{"role":"system","content":"You are a nice chatbot who greets people. Personalize your greeting if you know who the person is (for example, give them a specific compliment)"},{"role":"user","content":"Hello! My name is {{name}}"}]},"options":{"model":"gpt-5.4-pro-2026-03-05","params":{"use_cache":true,"temperature":0},"position":"0|hzzzzz:"}},"slug":"kind-greeter-0bd1","tags":null,"function_data":{"type":"prompt"}}]} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json new file mode 100644 index 00000000..4f2fad3c --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json @@ -0,0 +1 @@ +{"objects":[{"_xact_id":"1000197048220868163","created":"2026-04-23T20:44:09.171Z","description":null,"function_type":null,"id":"07961a58-38d9-490d-97c0-6a6ecbb9fa0b","log_id":"p","metadata":null,"name":"kind-greeter","org_id":"5d7c97d7-fef1-4cb7-bda6-7e3756a0ca8e","project_id":"6ae68365-7620-4630-921b-bac416634fc8","prompt_data":{"prompt":{"type":"chat","messages":[{"role":"system","content":"You are a nice chatbot who greets people. Personalize your greeting if you know who the person is (for example, give them a specific compliment)"},{"role":"user","content":"Hello! My name is {{name}}"}]},"options":{"model":"gpt-5.4-pro-2026-03-05","params":{"use_cache":true,"temperature":0},"position":"0|hzzzzz:"}},"slug":"kind-greeter-0bd1","tags":null,"function_data":{"type":"prompt"}}]} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json new file mode 100644 index 00000000..d6522e69 --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json @@ -0,0 +1,32 @@ +{ + "id" : "3204b2c0-c5cc-4c17-a61d-462e3aca8860", + "name" : "api_apikey_login", + "request" : { + "url" : "/api/apikey/login", + "method" : "POST" + }, + "response" : { + "status" : 200, + "bodyFileName" : "api_apikey_login-3204b2c0-c5cc-4c17-a61d-462e3aca8860.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "x-amz-apigw-id" : "cVwOqGHUoAMEtRw=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "395", + "X-Amz-Cf-Pop" : [ "SEA900-P1", "SEA900-P10" ], + "X-Amzn-Trace-Id" : "Root=1-69ebc72a-36f7176d40dbdc727981b020;Parent=469d3e90ccb339c5;Sampled=0;Lineage=1:24be3d11:0", + "Date" : "Fri, 24 Apr 2026 19:40:26 GMT", + "Via" : "1.1 d5e9313fa5148ebdba4664d3e2a90f58.cloudfront.net (CloudFront), 1.1 f8731007efc5ab360d90cee573a1e916.cloudfront.net (CloudFront)", + "access-control-expose-headers" : "x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "69ebc72a000000005025767c11047b48", + "x-amzn-RequestId" : "83c4cfb7-cc5d-455b-9ed2-da15b0151765", + "X-Amz-Cf-Id" : "NxipFPEicWOU0GP-N_9a4tY94ivdZ1CEZIGBys3yBeewbHi5pz1odg==", + "etag" : "W/\"18b-OPCBBHzVVuCPaglXVbFjmsFzOoE\"", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "3204b2c0-c5cc-4c17-a61d-462e3aca8860", + "persistent" : true, + "insertionIndex" : 174 +} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json new file mode 100644 index 00000000..944ddf3b --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json @@ -0,0 +1,39 @@ +{ + "id" : "7296feee-0e05-4970-9022-40f0769ac9c9", + "name" : "v1_project", + "request" : { + "urlPath" : "/v1/project", + "method" : "GET", + "queryParameters" : { + "project_name" : { + "hasExactly" : [ { + "equalTo" : "java-unit-test" + } ] + } + } + }, + "response" : { + "status" : 200, + "bodyFileName" : "v1_project-7296feee-0e05-4970-9022-40f0769ac9c9.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "x-amz-apigw-id" : "cVwOnHnhoAMEExA=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "366", + "X-Amz-Cf-Pop" : [ "SEA900-P1", "SEA900-P10" ], + "X-Amzn-Trace-Id" : "Root=1-69ebc729-147d2b5d5b24b8d649937ee8;Parent=1d2a896c1ad82339;Sampled=0;Lineage=1:24be3d11:0", + "Date" : "Fri, 24 Apr 2026 19:40:26 GMT", + "Via" : "1.1 db84db36e16ca0c80b0992006d731900.cloudfront.net (CloudFront), 1.1 e6b2537b87653726af8a79e6da505188.cloudfront.net (CloudFront)", + "access-control-expose-headers" : "x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "69ebc72900000000100f48dcde634bd9", + "x-amzn-RequestId" : "9940c25e-19f0-42a7-84c5-9740a90a50c7", + "X-Amz-Cf-Id" : "C8XrMlIlzeSNDoxModfFzEu0yiX98beOoo2DukQEHS0Eb1R86PG0qA==", + "etag" : "W/\"16e-7t9aAadD2qUSZTBI6wB6/kY8xEc\"", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "7296feee-0e05-4970-9022-40f0769ac9c9", + "persistent" : true, + "insertionIndex" : 175 +} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json new file mode 100644 index 00000000..3962bda0 --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json @@ -0,0 +1,46 @@ +{ + "id" : "63100248-0fb4-47d7-aa04-0986cbb792ef", + "name" : "v1_prompt", + "request" : { + "urlPath" : "/v1/prompt", + "method" : "GET", + "queryParameters" : { + "slug" : { + "hasExactly" : [ { + "equalTo" : "kind-greeter-0bd1" + } ] + }, + "project_name" : { + "hasExactly" : [ { + "equalTo" : "java-unit-test" + } ] + } + } + }, + "response" : { + "status" : 200, + "bodyFileName" : "v1_prompt-63100248-0fb4-47d7-aa04-0986cbb792ef.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "x-amz-apigw-id" : "cSnFaHAIIAMEiNQ=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "789", + "X-Amz-Cf-Pop" : [ "SEA900-P1", "SEA900-P10" ], + "X-Amzn-Trace-Id" : "Root=1-69ea8555-126390de7d13a1565e6fe5db;Parent=5d01411b5b955fa6;Sampled=0;Lineage=1:24be3d11:0", + "Date" : "Thu, 23 Apr 2026 20:47:17 GMT", + "Via" : "1.1 d08613e1dd8ad614e47875ae31a8af20.cloudfront.net (CloudFront), 1.1 0eb43913f9caf453beb959a8a836a688.cloudfront.net (CloudFront)", + "access-control-expose-headers" : "x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "69ea8555000000002ae9971193270400", + "x-amzn-RequestId" : "97fcdcf6-26df-4202-9429-7ac048fbff49", + "X-Amz-Cf-Id" : "vV3yu7EJYsMgzyiBfGFQrmCk2vs02iDvu3yKrWEeiDdVWz8wWGyIjg==", + "etag" : "W/\"315-aYopezw9/FDum2wzo3Z28/ESIc0\"", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "63100248-0fb4-47d7-aa04-0986cbb792ef", + "persistent" : true, + "scenarioName" : "scenario-1-v1-prompt", + "requiredScenarioState" : "scenario-1-v1-prompt-3", + "insertionIndex" : 170 +} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json new file mode 100644 index 00000000..83f7660f --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json @@ -0,0 +1,49 @@ +{ + "id" : "726960d6-e5ab-4433-a7fd-c501f2023702", + "name" : "v1_prompt", + "request" : { + "urlPath" : "/v1/prompt", + "method" : "GET", + "queryParameters" : { + "slug" : { + "hasExactly" : [ { + "equalTo" : "kind-greeter-0bd1" + } ] + }, + "project_name" : { + "hasExactly" : [ { + "equalTo" : "java-unit-test" + } ] + }, + "version" : { + "hasExactly" : [ { + "equalTo" : "27fdcc80d22c7ec5" + } ] + } + } + }, + "response" : { + "status" : 200, + "bodyFileName" : "v1_prompt-726960d6-e5ab-4433-a7fd-c501f2023702.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "x-amz-apigw-id" : "cSnFTEBAIAMEiIg=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "667", + "X-Amz-Cf-Pop" : [ "SEA900-P1", "SEA900-P10" ], + "X-Amzn-Trace-Id" : "Root=1-69ea8554-2642efbb37a02c423b2aacd3;Parent=1f054c68c9208e63;Sampled=0;Lineage=1:24be3d11:0", + "Date" : "Thu, 23 Apr 2026 20:47:16 GMT", + "Via" : "1.1 724581b48d733e53834b535d2a623034.cloudfront.net (CloudFront), 1.1 65f2e9f7f1475de54aa452d3ceb9bcf6.cloudfront.net (CloudFront)", + "access-control-expose-headers" : "x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "69ea8554000000007baaaf3ed94cde4d", + "x-amzn-RequestId" : "1bedce05-3eb2-402c-b80d-6460930343d0", + "X-Amz-Cf-Id" : "OkJ0AJfrrmG20UoM1YrRXr5bI1iHNm8g4RzmO50VufnDz5cDg2Q6-w==", + "etag" : "W/\"29b-OevfOPDm+UyiFlombyUf3PTTPEk\"", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "726960d6-e5ab-4433-a7fd-c501f2023702", + "persistent" : true, + "insertionIndex" : 173 +} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json new file mode 100644 index 00000000..239177cb --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json @@ -0,0 +1,47 @@ +{ + "id" : "a6fefcc2-93e1-4076-bef6-a6039b9d8b61", + "name" : "v1_prompt", + "request" : { + "urlPath" : "/v1/prompt", + "method" : "GET", + "queryParameters" : { + "slug" : { + "hasExactly" : [ { + "equalTo" : "kind-greeter-0bd1" + } ] + }, + "project_name" : { + "hasExactly" : [ { + "equalTo" : "java-unit-test" + } ] + } + } + }, + "response" : { + "status" : 200, + "bodyFileName" : "v1_prompt-a6fefcc2-93e1-4076-bef6-a6039b9d8b61.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "x-amz-apigw-id" : "cSnFYHsjIAMEq9g=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "789", + "X-Amz-Cf-Pop" : [ "SEA900-P1", "SEA900-P10" ], + "X-Amzn-Trace-Id" : "Root=1-69ea8555-05adf73d7e3d44a0633d139e;Parent=5ed890dc9da1918d;Sampled=0;Lineage=1:24be3d11:0", + "Date" : "Thu, 23 Apr 2026 20:47:17 GMT", + "Via" : "1.1 79a7455da856598d6db0b6edabec6574.cloudfront.net (CloudFront), 1.1 566cc276dff9847158a5a9854be4df42.cloudfront.net (CloudFront)", + "access-control-expose-headers" : "x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "69ea855500000000405e174346edeb85", + "x-amzn-RequestId" : "b1bf713c-8018-4651-8383-94b1bdbe0e44", + "X-Amz-Cf-Id" : "VOBEIjWaMU4tHE_d4S9NM4xTJHlvCIfnoulquQ8auInHwf2KLd5lOA==", + "etag" : "W/\"315-aYopezw9/FDum2wzo3Z28/ESIc0\"", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "a6fefcc2-93e1-4076-bef6-a6039b9d8b61", + "persistent" : true, + "scenarioName" : "scenario-1-v1-prompt", + "requiredScenarioState" : "scenario-1-v1-prompt-2", + "newScenarioState" : "scenario-1-v1-prompt-3", + "insertionIndex" : 171 +} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json new file mode 100644 index 00000000..a0bc6b30 --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json @@ -0,0 +1,47 @@ +{ + "id" : "d5b36c34-4fd3-483e-babe-5bde6277bfe5", + "name" : "v1_prompt", + "request" : { + "urlPath" : "/v1/prompt", + "method" : "GET", + "queryParameters" : { + "slug" : { + "hasExactly" : [ { + "equalTo" : "kind-greeter-0bd1" + } ] + }, + "project_name" : { + "hasExactly" : [ { + "equalTo" : "java-unit-test" + } ] + } + } + }, + "response" : { + "status" : 200, + "bodyFileName" : "v1_prompt-d5b36c34-4fd3-483e-babe-5bde6277bfe5.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "x-amz-apigw-id" : "cSnFWEJ5IAMEf6A=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "789", + "X-Amz-Cf-Pop" : [ "SEA900-P1", "SEA900-P10" ], + "X-Amzn-Trace-Id" : "Root=1-69ea8555-2f880391090e6feb0d121086;Parent=60b91fab740db42f;Sampled=0;Lineage=1:24be3d11:0", + "Date" : "Thu, 23 Apr 2026 20:47:17 GMT", + "Via" : "1.1 2c24d855455b80190edd9e2dcdca3ee8.cloudfront.net (CloudFront), 1.1 ee5f8da78d4211a93c9dba8864a4067e.cloudfront.net (CloudFront)", + "access-control-expose-headers" : "x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "69ea85550000000074ff37eb7f08b505", + "x-amzn-RequestId" : "c119726c-832a-407d-ad34-826ac6bc07aa", + "X-Amz-Cf-Id" : "Vx07exy9XN7d61O8P0fIoj0e5fWV9nh-mKzRrm7Rk81QKSTRpphmhg==", + "etag" : "W/\"315-aYopezw9/FDum2wzo3Z28/ESIc0\"", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "d5b36c34-4fd3-483e-babe-5bde6277bfe5", + "persistent" : true, + "scenarioName" : "scenario-1-v1-prompt", + "requiredScenarioState" : "Started", + "newScenarioState" : "scenario-1-v1-prompt-2", + "insertionIndex" : 172 +} \ No newline at end of file