diff --git a/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java b/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java index f83024af..5a5ba9c9 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/devserver/Devserver.java @@ -377,10 +377,31 @@ private void handleStreamingEval( BraintrustUtils.createProjectURI( braintrust.config().appUrl(), orgName, projectName) .toASCIIString(); - final var experimentUrl = projectUrl + "/experiments/" + experimentName; var tracer = BraintrustTracing.getTracer(); + // A remote eval can be triggered two ways: + // 1. Playground run: the request carries a `parent` object (playground_id). We + // stream per-case progress under that parent (handled by the loop below). + // 2. Experiment "snapshot": no such parent (instead experiment_name + project). + // We run a standard Eval, which creates a real experiment and emits standard + // spans, then send a summary + done with the experiment link. + final var playgroundParent = extractPlaygroundParent(request); + if (playgroundParent.isEmpty()) { + handleExperimentSnapshot( + os, + eval, + request, + braintrust, + apiClient, + projectId, + projectName, + projectUrl, + experimentName, + remoteScorers); + return; + } + // Merge parameters: evaluator defaults + request overrides final Parameters mergedParameters = new Parameters( @@ -391,7 +412,7 @@ private void handleStreamingEval( // Execute task and scorers for each case final Map> scoresByName = new ConcurrentHashMap<>(); - final var parentInfo = extractParentInfo(request); + final var parentInfo = playgroundParent.get(); final var braintrustParent = parentInfo.braintrustParent(); final var braintrustGeneration = parentInfo.generation(); @@ -547,13 +568,7 @@ private void handleStreamingEval( } sendSummaryEvent( - os, - projectName, - projectId, - experimentName, - projectUrl, - experimentUrl, - scoreSummaries); + os, projectName, projectId, experimentName, projectUrl, scoreSummaries); sendDoneEvent(os); } catch (Exception e) { // Send error event via SSE @@ -577,6 +592,73 @@ private void handleStreamingEval( } } + /** + * Handles an experiment "snapshot" run: a remote eval triggered as an Experiment from the UI + * (no playground parent). Rather than re-implementing experiment creation and span emission, it + * builds a first-class {@link Eval} and runs it synchronously — so snapshots get the exact same + * behavior as a normal {@code Eval.run()} (experiment creation with {@code ensure_new}, dataset + * id/version linkage for Braintrust-backed datasets, standard span shape). When the run + * completes it streams a single {@code summary} (with the created experiment's id/name/url) and + * a {@code done} event. + * + *

Unlike playground runs, snapshots do not stream per-case {@code progress} events: the user + * is handed the experiment link and views results in the experiment UI. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void handleExperimentSnapshot( + OutputStream os, + RemoteEval eval, + EvalRequest request, + Braintrust braintrust, + BraintrustOpenApiClient apiClient, + String projectId, + String projectName, + String projectUrl, + String experimentName, + List> remoteScorers) + throws IOException { + // Combine local scorers (from the RemoteEval) with remote scorers (from the request). + List> allScorers = new ArrayList<>(eval.getScorers()); + allScorers.addAll(remoteScorers); + + // TODO: when async evals are supported, simply begin the eval and hand back the link to the + // user and finish eval in the background + + var evalResult = + Eval.builder() + .name(experimentName) + .config(braintrust.config()) + .apiClient(apiClient) + .projectId(projectId) + .dataset((Dataset) extractDataset(request, apiClient)) + .task(eval.getTask()) + .scorers(allScorers.toArray(new Scorer[0])) + .parameters(eval.getParameters()) + .parameterValues( + request.getParameters() == null + ? Map.of() + : request.getParameters()) + // Each snapshot run should produce a distinct experiment even if a prior + // run used the same name (the backend dedupes the name on conflict). + .ensureNew(true) + .build() + .run(); + + // Snapshots don't stream per-scorer progress. The scores are recorded on the experiment + // and visible via the experiment link. + sendExperimentSnapshotSummaryEvent( + os, + projectName, + projectId, + evalResult.getExperimentId(), + evalResult.getExperimentName() != null + ? evalResult.getExperimentName() + : experimentName, + projectUrl, + evalResult.getExperimentUrl()); + sendExperimentSnapshotDoneEvent(os); + } + private void setEvalSpanAttributes( Span evalSpan, BraintrustUtils.Parent braintrustParent, @@ -802,7 +884,6 @@ private void sendSummaryEvent( String projectId, String experimentName, String projectUrl, - String experimentUrl, Map scoreSummaries) throws IOException { Map summary = new LinkedHashMap<>(); @@ -835,6 +916,40 @@ private void sendDoneEvent(OutputStream os) throws IOException { sendSSEEvent(os, "done", ""); } + /** + * Sends the {@code summary} event for an experiment snapshot run. Unlike {@link + * #sendSummaryEvent} (playground), this references the created experiment via {@code + * experimentId}/{@code experimentUrl} so the UI can link straight to it, and carries no + * streamed per-scorer scores (results live on the experiment itself). + */ + private void sendExperimentSnapshotSummaryEvent( + OutputStream os, + String projectName, + String projectId, + @Nullable String experimentId, + String experimentName, + String projectUrl, + @Nullable String experimentUrl) + throws IOException { + Map summary = new LinkedHashMap<>(); + summary.put("projectName", projectName); + summary.put("projectId", projectId); + summary.put("experimentId", experimentId); + summary.put("experimentName", experimentName); + summary.put("projectUrl", projectUrl); + summary.put("experimentUrl", experimentUrl); + summary.put("comparisonExperimentName", null); + summary.put("scores", Map.of()); + summary.put("metrics", Map.of()); + + sendSSEEvent(os, "summary", toJson(summary)); + } + + /** Sends the terminating {@code done} event for an experiment snapshot run. */ + private void sendExperimentSnapshotDoneEvent(OutputStream os) throws IOException { + sendSSEEvent(os, "done", ""); + } + private void sendResponse( HttpExchange exchange, int statusCode, String contentType, String body) throws IOException { @@ -1105,44 +1220,50 @@ private record ParentInfo( @Nonnull BraintrustUtils.Parent braintrustParent, @Nullable String generation) {} /** - * Extracts parent information from the eval request. + * Extracts the playground parent (and generation) from the eval request, if present. + * + *

Playground runs send a {@code parent} object carrying {@code object_type}/{@code + * object_id}; experiment ("snapshot") runs send no such parent (instead {@code experiment_name} + * + project via headers) and are handled by {@link #handleExperimentSnapshot}. Returns empty + * for the latter case. * * @param request The eval request - * @return ParentInfo containing braintrustParent and generation + * @return the playground {@link ParentInfo} if this is a playground run, else empty */ - private static ParentInfo extractParentInfo(EvalRequest request) { - String parentSpec = null; - String generation = null; + private static Optional extractPlaygroundParent(EvalRequest request) { + if (!(request.getParent() instanceof Map)) { + return Optional.empty(); + } + @SuppressWarnings("unchecked") + Map parentMap = (Map) request.getParent(); + String objectType = (String) parentMap.get("object_type"); + String objectId = (String) parentMap.get("object_id"); + if (objectType == null && objectId == null) { + return Optional.empty(); + } - // Extract parent spec and generation from request - if (request.getParent() != null && request.getParent() instanceof Map) { - @SuppressWarnings("unchecked") - Map parentMap = (Map) request.getParent(); - String objectType = (String) parentMap.get("object_type"); - String objectId = (String) parentMap.get("object_id"); + if (objectType == null || objectId == null) { + throw new IllegalArgumentException( + "malformed braintrust parent: %s, %s".formatted(objectType, objectId)); + } - // Extract generation from propagated_event.span_attributes.generation - Object propEventObj = parentMap.get("propagated_event"); - if (propEventObj instanceof Map) { + // Extract generation from propagated_event.span_attributes.generation + String generation = null; + Object propEventObj = parentMap.get("propagated_event"); + if (propEventObj instanceof Map) { + @SuppressWarnings("unchecked") + Map propEvent = (Map) propEventObj; + Object spanAttrsObj = propEvent.get("span_attributes"); + if (spanAttrsObj instanceof Map) { @SuppressWarnings("unchecked") - Map propEvent = (Map) propEventObj; - Object spanAttrsObj = propEvent.get("span_attributes"); - if (spanAttrsObj instanceof Map) { - @SuppressWarnings("unchecked") - Map spanAttrs = (Map) spanAttrsObj; - generation = (String) spanAttrs.get("generation"); - } - } - - if (objectType != null && objectId != null) { - parentSpec = "playground_id:" + objectId; + Map spanAttrs = (Map) spanAttrsObj; + generation = (String) spanAttrs.get("generation"); } } - if (parentSpec == null) { - throw new IllegalArgumentException("braintrust parent (playground_id) not found"); - } - return new ParentInfo(BraintrustUtils.parseParent(parentSpec), generation); + return Optional.of( + new ParentInfo( + BraintrustUtils.parseParent("playground_id:" + objectId), generation)); } /** diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/Eval.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/Eval.java index a9c3d06b..fc9e12c0 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/Eval.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/Eval.java @@ -46,6 +46,7 @@ public final class Eval { private final @Nonnull List tags; private final @Nonnull Map metadata; private final @Nonnull Parameters parameters; + private final boolean ensureNew; private Eval(Builder builder) { this.experimentName = builder.experimentName; @@ -63,6 +64,7 @@ private Eval(Builder builder) { this.tags = List.copyOf(builder.tags); this.metadata = Map.copyOf(builder.metadata); this.parameters = builder.buildParameters(); + this.ensureNew = builder.ensureNew; } /** Runs the evaluation and returns results. */ @@ -78,6 +80,9 @@ public EvalResult run() { var createExperiment = new CreateExperiment().projectId(project.getId()).name(experimentName); + if (ensureNew) { + createExperiment.ensureNew(true); + } if (!tags.isEmpty()) { createExperiment.tags(tags); } @@ -90,16 +95,22 @@ public EvalResult run() { var experiment = new ExperimentsApi(client).postExperiment(createExperiment); cursor.forEach(datasetCase -> evalOne(experiment.getId().toString(), datasetCase)); - } - var experimentUrl = - "%s/experiments/%s" - .formatted( - BraintrustUtils.createProjectURI( - config.appUrl(), orgInfo.name(), project.getName()) - .toASCIIString(), - experimentName); - return new EvalResult(experimentUrl); + // Use the experiment's actual name from the response: with ensure_new the backend may + // dedupe a conflicting name (e.g. "foo" -> "foo-2f8ca776"), and the URL must point at + // the real, created experiment. + var resolvedName = experiment.getName() != null ? experiment.getName() : experimentName; + var experimentUrl = + "%s/experiments/%s" + .formatted( + BraintrustUtils.createProjectURI( + config.appUrl(), + orgInfo.name(), + project.getName()) + .toASCIIString(), + resolvedName); + return new EvalResult(experiment.getId().toString(), resolvedName, experimentUrl); + } } private void evalOne(String experimentId, DatasetCase datasetCase) { @@ -397,6 +408,7 @@ public static final class Builder { private @Nonnull Map parameterValues = Map.of(); private @Nonnull List tags = List.of(); private @Nonnull Map metadata = Map.of(); + private boolean ensureNew = false; public Eval build() { if (config == null) { @@ -521,6 +533,17 @@ public Builder metadata(Map metadata) { return this; } + /** + * When {@code true}, sets {@code ensure_new} on the create-experiment request so a new + * experiment is always created even if one with the same name already exists (the backend + * dedupes the name on conflict). Useful for repeated runs (e.g. UI/remote snapshots) that + * should each produce a distinct experiment. Defaults to {@code false}. + */ + public Builder ensureNew(boolean ensureNew) { + this.ensureNew = ensureNew; + return this; + } + /** * Sets parameter definitions for this eval. Default values from the definitions are used * unless overridden via {@link #parameterValues(Map)}. diff --git a/braintrust-sdk/src/main/java/dev/braintrust/eval/EvalResult.java b/braintrust-sdk/src/main/java/dev/braintrust/eval/EvalResult.java index 70297d4e..7b2b44b4 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/eval/EvalResult.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/eval/EvalResult.java @@ -1,14 +1,20 @@ package dev.braintrust.eval; +import javax.annotation.Nullable; import lombok.Getter; import lombok.SneakyThrows; /** Results of all eval cases of an experiment. */ public class EvalResult { + @Getter private final @Nullable String experimentId; + @Getter private final @Nullable String experimentName; @Getter private final String experimentUrl; @SneakyThrows - EvalResult(String experimentUrl) { + EvalResult( + @Nullable String experimentId, @Nullable String experimentName, String experimentUrl) { + this.experimentId = experimentId; + this.experimentName = experimentName; this.experimentUrl = experimentUrl; } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java b/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java index e1755a03..04b2e2d4 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/devserver/DevserverTest.java @@ -543,6 +543,156 @@ void testStreamingEval() throws Exception { } } + @Test + void testExperimentEval() throws Exception { + // A remote eval triggered as an *Experiment* (snapshot) from the UI sends no playground + // parent — instead an experiment_name (+ project via headers). The devserver should build + // and run a standard Eval, which creates a fresh experiment and parents spans to + // experiment_id:. It then streams only summary + done (no per-case progress). + final String experimentName = "java-experiment-repro"; + + EvalRequest evalRequest = new EvalRequest(); + evalRequest.setName(REMOTE_EVAL_NAME); + evalRequest.setStream(true); + evalRequest.setExperimentName(experimentName); + // Note: no parent set -> experiment-snapshot path. + + EvalRequest.DataSpec dataSpec = new EvalRequest.DataSpec(); + EvalRequest.EvalCaseData case1 = new EvalRequest.EvalCaseData(); + case1.setInput("apple"); + case1.setExpected("fruit"); + EvalRequest.EvalCaseData case2 = new EvalRequest.EvalCaseData(); + case2.setInput("carrot"); + case2.setExpected("vegetable"); + dataSpec.setData(List.of(case1, case2)); + evalRequest.setData(dataSpec); + + String requestBody = JSON_MAPPER.writeValueAsString(evalRequest); + + HttpURLConnection conn = + (HttpURLConnection) new URI(TEST_URL + "/eval").toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-bt-auth-token", testHarness.braintrustApiKey()); + conn.setRequestProperty("x-bt-project-id", TestHarness.defaultProjectId()); + conn.setRequestProperty("x-bt-org-name", TestHarness.defaultOrgName()); + conn.setDoOutput(true); + conn.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8)); + conn.getOutputStream().flush(); + + assertEquals(200, conn.getResponseCode()); + + List> events = readSSEEvents(conn); + + List> progressEvents = + events.stream().filter(e -> "progress".equals(e.get("event"))).toList(); + List> summaryEvents = + events.stream().filter(e -> "summary".equals(e.get("event"))).toList(); + List> doneEvents = + events.stream().filter(e -> "done".equals(e.get("event"))).toList(); + // Snapshots don't stream per-case progress: only a terminating summary + done. + assertEquals(0, progressEvents.size(), "Snapshot runs should not send progress events"); + assertEquals(1, summaryEvents.size(), "Should have 1 summary event"); + assertEquals(1, doneEvents.size(), "Should have 1 done event"); + + // The summary should reference the created experiment (unlike playground runs). The id is + // whatever the backend assigned — derive it from the summary rather than hardcoding. + JsonNode summaryData = JSON_MAPPER.readTree(summaryEvents.get(0).get("data")); + assertEquals(TestHarness.defaultProjectName(), summaryData.get("projectName").asText()); + assertTrue(summaryData.get("experimentName").asText().startsWith(experimentName)); + assertFalse( + summaryData.get("experimentId").isNull(), + "experiment run should have an experimentId"); + assertFalse( + summaryData.get("experimentUrl").isNull(), + "experiment run should have an experimentUrl"); + String experimentId = summaryData.get("experimentId").asText(); + + // Spans should be parented to experiment_id: using the standard Eval span shape. + List allSpans = testHarness.awaitExportedSpans(); + String expectedParent = "experiment_id:" + experimentId; + var evalSpans = + allSpans.stream() + .filter(s -> s.getName().equals("eval")) + .filter( + s -> + expectedParent.equals( + s.getAttributes() + .get( + AttributeKey.stringKey( + "braintrust.parent")))) + .toList(); + assertEquals(2, evalSpans.size(), "Should have 2 eval spans parented to the experiment"); + + for (SpanData evalSpan : evalSpans) { + JsonNode spanAttrs = + JSON_MAPPER.readTree( + evalSpan.getAttributes() + .get(AttributeKey.stringKey("braintrust.span_attributes"))); + assertEquals("eval", spanAttrs.get("type").asText()); + // Standard Eval span shape uses braintrust.expected (not the playground's + // expected_json) and carries no playground-specific "generation" key. + assertFalse( + spanAttrs.has("generation"), "experiment spans should not carry generation"); + assertNotNull( + evalSpan.getAttributes().get(AttributeKey.stringKey("braintrust.expected")), + "standard Eval decorator should set braintrust.expected"); + } + } + + @Test + void testMalformedParent() throws Exception { + // A parent with only one of object_type/object_id set is neither a valid playground run + // nor an experiment snapshot (which sends no parent at all). extractPlaygroundParent + // rejects it with an IllegalArgumentException, which is streamed back as an SSE error + // event. + EvalRequest evalRequest = new EvalRequest(); + evalRequest.setName(REMOTE_EVAL_NAME); + evalRequest.setStream(true); + + EvalRequest.DataSpec dataSpec = new EvalRequest.DataSpec(); + EvalRequest.EvalCaseData case1 = new EvalRequest.EvalCaseData(); + case1.setInput("apple"); + case1.setExpected("fruit"); + dataSpec.setData(List.of(case1)); + evalRequest.setData(dataSpec); + + // object_type present but object_id missing -> malformed parent. + Map parentSpec = Map.of("object_type", PLAYGROUND_PARENT.type()); + evalRequest.setParent(parentSpec); + + String requestBody = JSON_MAPPER.writeValueAsString(evalRequest); + + HttpURLConnection conn = + (HttpURLConnection) new URI(TEST_URL + "/eval").toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-bt-auth-token", testHarness.braintrustApiKey()); + conn.setRequestProperty("x-bt-project-id", TestHarness.defaultProjectId()); + conn.setRequestProperty("x-bt-org-name", TestHarness.defaultOrgName()); + conn.setDoOutput(true); + conn.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8)); + conn.getOutputStream().flush(); + + // Streaming has already begun, so the failure surfaces as an SSE error event (not an HTTP + // error status). + assertEquals(200, conn.getResponseCode()); + + List> events = readSSEEvents(conn); + + List> errorEvents = + events.stream().filter(e -> "error".equals(e.get("event"))).toList(); + assertEquals(1, errorEvents.size(), "Malformed parent should produce a single error event"); + assertTrue( + errorEvents.get(0).get("data").contains("malformed braintrust parent"), + "Error message should mention the malformed parent"); + + // A malformed parent should abort before any experiment is created or summarized. + assertTrue( + events.stream().noneMatch(e -> "summary".equals(e.get("event"))), + "Malformed parent should not produce a summary event"); + } + @Test void testEvaluatorNotFound() throws Exception { EvalRequest request = new EvalRequest(); 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 40227f08..17d712a1 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/eval/DatasetBrainstoreImplTest.java @@ -265,6 +265,47 @@ void testMetadataPopulatedFromDatasetRow() { assertEquals("user123", metadata.get("userId")); } + @Test + void testTagsPopulatedFromDatasetRow() { + wireMock.stubFor( + post(urlEqualTo("/v1/dataset/" + datasetId + "/fetch")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "events": [ + { + "object_type": "dataset", + "dataset_id": "%s", + "id": "meta-row-1", + "_xact_id": "1", + "created": "2024-01-01T00:00:00Z", + "input": "test input", + "expected": "test output", + "tags": ["unit-test"] + } + ], + "cursor": null + } + """ + .formatted(datasetId)))); + + DatasetBrainstoreImpl dataset = + new DatasetBrainstoreImpl<>(apiClient, datasetId, "test-version"); + + List> cases = new ArrayList<>(); + dataset.forEach(cases::add); + + assertEquals(1, cases.size()); + List tags = cases.get(0).tags(); + assertFalse(tags.isEmpty(), "tags should not be empty"); + assertEquals(1, tags.size()); + assertEquals("unit-test", tags.get(0)); + } + @Test void testFetchFromBraintrustNotFound() { String projectName = "test-project"; diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_experiment-b23c58a28a5b.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_experiment-b23c58a28a5b.json new file mode 100644 index 00000000..95a5a07a --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/__files/v1_experiment-b23c58a28a5b.json @@ -0,0 +1 @@ +{"id":"123473a3-e1af-4cca-a9d3-b8551a959f61","project_id":"f1e858a4-58e3-408f-983f-016760d7fa25","name":"java-experiment-repro-f6c2c15a","description":null,"created":"2026-07-01T17:29:14.164Z","repo_info":null,"commit":null,"base_exp_id":null,"deleted_at":null,"dataset_id":null,"dataset_version":null,"internal_metadata":null,"parameters_id":null,"parameters_version":null,"public":false,"user_id":"a5ca7f9c-bf20-40c4-a82b-5c992f6a38f5","metadata":null,"tags":null} \ No newline at end of file diff --git a/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_experiment-b23c58a28a5b.json b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_experiment-b23c58a28a5b.json new file mode 100644 index 00000000..f2bb0ffd --- /dev/null +++ b/test-harness/src/testFixtures/resources/cassettes/braintrust/mappings/v1_experiment-b23c58a28a5b.json @@ -0,0 +1,45 @@ +{ + "id" : "f0bf4ccb-ccb4-3056-affc-61b178ead1c4", + "name" : "v1_experiment", + "request" : { + "url" : "/v1/experiment", + "method" : "POST", + "headers" : { + "Content-Type" : { + "equalTo" : "application/json" + } + }, + "bodyPatterns" : [ { + "equalToJson" : "{\"project_id\":\"f1e858a4-58e3-408f-983f-016760d7fa25\",\"name\":\"java-experiment-repro\",\"ensure_new\":true}", + "ignoreArrayOrder" : true, + "ignoreExtraElements" : false + } ] + }, + "response" : { + "status" : 200, + "bodyFileName" : "v1_experiment-b23c58a28a5b.json", + "headers" : { + "X-Cache" : "Miss from cloudfront", + "expires" : "0", + "x-amz-apigw-id" : "f1kwoE4aoAMEXmg=", + "vary" : "Origin, Accept-Encoding", + "x-amzn-Remapped-content-length" : "466", + "X-Amz-Cf-Pop" : [ "SEA900-P10", "SEA900-P9" ], + "X-Amzn-Trace-Id" : "Root=1-6a454e6a-747e473e09a004224201fe22;Parent=6f5343f5544a6754;Sampled=0;Lineage=1:fc3b4ff1:0", + "Date" : "Wed, 01 Jul 2026 17:29:14 GMT", + "Via" : "1.1 b669d9add7767f73665f1f8b7e8cd802.cloudfront.net (CloudFront), 1.1 d49bde7225e80ca0dc457ff2b8b4343e.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,x-bt-error-origin,x-bt-used-endpoint", + "access-control-allow-credentials" : "true", + "x-bt-internal-trace-id" : "6a454e6a0000000025ad31443b9fc415", + "x-amzn-RequestId" : "4be097f3-2353-4649-98a7-c0bafc1b2956", + "X-Amz-Cf-Id" : "Vz-aNJEkBtvye6O2gPxE5g16lR4THiAAvMTxLbV_NAB9f6ViAmB4Sw==", + "etag" : "W/\"1d2-2vX1I97rHgKA0rtbQJQk3D0j8Xs\"", + "cache-control" : "no-store, no-cache, must-revalidate, proxy-revalidate", + "surrogate-control" : "no-store", + "Content-Type" : "application/json; charset=utf-8" + } + }, + "uuid" : "f0bf4ccb-ccb4-3056-affc-61b178ead1c4", + "persistent" : true, + "insertionIndex" : 141 +} \ No newline at end of file