@arizeai/phoenix-client 6.10.1 → 6.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/dist/esm/__generated__/api/v1.d.ts +254 -2
- package/dist/esm/__generated__/api/v1.d.ts.map +1 -1
- package/dist/esm/jest/index.d.ts +5 -0
- package/dist/esm/jest/index.d.ts.map +1 -0
- package/dist/esm/jest/index.js +49 -0
- package/dist/esm/jest/index.js.map +1 -0
- package/dist/esm/jest/reporter.d.ts +13 -0
- package/dist/esm/jest/reporter.d.ts.map +1 -0
- package/dist/esm/jest/reporter.js +19 -0
- package/dist/esm/jest/reporter.js.map +1 -0
- package/dist/esm/prompts/sdks/toAI.d.ts +2 -2
- package/dist/esm/prompts/sdks/toAI.d.ts.map +1 -1
- package/dist/esm/prompts/sdks/toAI.js.map +1 -1
- package/dist/esm/prompts/sdks/toAnthropic.d.ts +2 -2
- package/dist/esm/prompts/sdks/toAnthropic.d.ts.map +1 -1
- package/dist/esm/prompts/sdks/toAnthropic.js.map +1 -1
- package/dist/esm/prompts/sdks/toOpenAI.d.ts +2 -2
- package/dist/esm/prompts/sdks/toOpenAI.d.ts.map +1 -1
- package/dist/esm/prompts/sdks/toOpenAI.js.map +1 -1
- package/dist/esm/prompts/sdks/toSDK.d.ts +8 -8
- package/dist/esm/prompts/sdks/toSDK.d.ts.map +1 -1
- package/dist/esm/prompts/sdks/toSDK.js.map +1 -1
- package/dist/esm/prompts/sdks/types.d.ts +2 -2
- package/dist/esm/prompts/sdks/types.d.ts.map +1 -1
- package/dist/esm/schemas/llm/anthropic/converters.d.ts +8 -8
- package/dist/esm/schemas/llm/anthropic/messagePartSchemas.d.ts +4 -4
- package/dist/esm/schemas/llm/anthropic/messageSchemas.d.ts +6 -6
- package/dist/esm/schemas/llm/constants.d.ts +3 -3
- package/dist/esm/schemas/llm/converters.d.ts +12 -12
- package/dist/esm/schemas/llm/openai/converters.d.ts +3 -3
- package/dist/esm/schemas/llm/schemas.d.ts +2 -2
- package/dist/esm/testing/acceptance.d.ts +20 -0
- package/dist/esm/testing/acceptance.d.ts.map +1 -0
- package/dist/esm/testing/acceptance.js +129 -0
- package/dist/esm/testing/acceptance.js.map +1 -0
- package/dist/esm/testing/define-api.d.ts +157 -0
- package/dist/esm/testing/define-api.d.ts.map +1 -0
- package/dist/esm/testing/define-api.js +78 -0
- package/dist/esm/testing/define-api.js.map +1 -0
- package/dist/esm/testing/helpers.d.ts +55 -0
- package/dist/esm/testing/helpers.d.ts.map +1 -0
- package/dist/esm/testing/helpers.js +179 -0
- package/dist/esm/testing/helpers.js.map +1 -0
- package/dist/esm/testing/phoenix-test-tracking.d.ts +68 -0
- package/dist/esm/testing/phoenix-test-tracking.d.ts.map +1 -0
- package/dist/esm/testing/phoenix-test-tracking.js +521 -0
- package/dist/esm/testing/phoenix-test-tracking.js.map +1 -0
- package/dist/esm/testing/report-artifacts.d.ts +45 -0
- package/dist/esm/testing/report-artifacts.d.ts.map +1 -0
- package/dist/esm/testing/report-artifacts.js +218 -0
- package/dist/esm/testing/report-artifacts.js.map +1 -0
- package/dist/esm/testing/report-run.d.ts +22 -0
- package/dist/esm/testing/report-run.d.ts.map +1 -0
- package/dist/esm/testing/report-run.js +41 -0
- package/dist/esm/testing/report-run.js.map +1 -0
- package/dist/esm/testing/reporter-format.d.ts +83 -0
- package/dist/esm/testing/reporter-format.d.ts.map +1 -0
- package/dist/esm/testing/reporter-format.js +852 -0
- package/dist/esm/testing/reporter-format.js.map +1 -0
- package/dist/esm/testing/runner.d.ts +31 -0
- package/dist/esm/testing/runner.d.ts.map +1 -0
- package/dist/esm/testing/runner.js +238 -0
- package/dist/esm/testing/runner.js.map +1 -0
- package/dist/esm/testing/state.d.ts +138 -0
- package/dist/esm/testing/state.d.ts.map +1 -0
- package/dist/esm/testing/state.js +31 -0
- package/dist/esm/testing/state.js.map +1 -0
- package/dist/esm/testing/types.d.ts +319 -0
- package/dist/esm/testing/types.d.ts.map +1 -0
- package/dist/esm/testing/types.js +9 -0
- package/dist/esm/testing/types.js.map +1 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/utils/channel.d.ts +7 -7
- package/dist/esm/utils/channel.d.ts.map +1 -1
- package/dist/esm/utils/channel.js +1 -1
- package/dist/esm/utils/channel.js.map +1 -1
- package/dist/esm/utils/formatPromptMessages.d.ts.map +1 -1
- package/dist/esm/utils/getPromptBySelector.d.ts.map +1 -1
- package/dist/esm/utils/promisifyResult.d.ts +1 -1
- package/dist/esm/utils/promisifyResult.d.ts.map +1 -1
- package/dist/esm/utils/promisifyResult.js.map +1 -1
- package/dist/esm/utils/schemaMatches.d.ts +5 -5
- package/dist/esm/utils/schemaMatches.d.ts.map +1 -1
- package/dist/esm/utils/schemaMatches.js.map +1 -1
- package/dist/esm/vitest/index.d.ts +5 -0
- package/dist/esm/vitest/index.d.ts.map +1 -0
- package/dist/esm/vitest/index.js +15 -0
- package/dist/esm/vitest/index.js.map +1 -0
- package/dist/esm/vitest/reporter.d.ts +19 -0
- package/dist/esm/vitest/reporter.d.ts.map +1 -0
- package/dist/esm/vitest/reporter.js +27 -0
- package/dist/esm/vitest/reporter.js.map +1 -0
- package/dist/src/__generated__/api/v1.d.ts +254 -2
- package/dist/src/__generated__/api/v1.d.ts.map +1 -1
- package/dist/src/jest/index.d.ts +5 -0
- package/dist/src/jest/index.d.ts.map +1 -0
- package/dist/src/jest/index.js +58 -0
- package/dist/src/jest/index.js.map +1 -0
- package/dist/src/jest/reporter.d.ts +13 -0
- package/dist/src/jest/reporter.d.ts.map +1 -0
- package/dist/src/jest/reporter.js +23 -0
- package/dist/src/jest/reporter.js.map +1 -0
- package/dist/src/prompts/sdks/toAI.d.ts +2 -2
- package/dist/src/prompts/sdks/toAI.d.ts.map +1 -1
- package/dist/src/prompts/sdks/toAI.js.map +1 -1
- package/dist/src/prompts/sdks/toAnthropic.d.ts +2 -2
- package/dist/src/prompts/sdks/toAnthropic.d.ts.map +1 -1
- package/dist/src/prompts/sdks/toAnthropic.js.map +1 -1
- package/dist/src/prompts/sdks/toOpenAI.d.ts +2 -2
- package/dist/src/prompts/sdks/toOpenAI.d.ts.map +1 -1
- package/dist/src/prompts/sdks/toOpenAI.js.map +1 -1
- package/dist/src/prompts/sdks/toSDK.d.ts +8 -8
- package/dist/src/prompts/sdks/toSDK.d.ts.map +1 -1
- package/dist/src/prompts/sdks/toSDK.js.map +1 -1
- package/dist/src/prompts/sdks/types.d.ts +2 -2
- package/dist/src/prompts/sdks/types.d.ts.map +1 -1
- package/dist/src/schemas/llm/anthropic/converters.d.ts +8 -8
- package/dist/src/schemas/llm/anthropic/messagePartSchemas.d.ts +4 -4
- package/dist/src/schemas/llm/anthropic/messageSchemas.d.ts +6 -6
- package/dist/src/schemas/llm/constants.d.ts +3 -3
- package/dist/src/schemas/llm/converters.d.ts +12 -12
- package/dist/src/schemas/llm/openai/converters.d.ts +3 -3
- package/dist/src/schemas/llm/schemas.d.ts +2 -2
- package/dist/src/testing/acceptance.d.ts +20 -0
- package/dist/src/testing/acceptance.d.ts.map +1 -0
- package/dist/src/testing/acceptance.js +114 -0
- package/dist/src/testing/acceptance.js.map +1 -0
- package/dist/src/testing/define-api.d.ts +157 -0
- package/dist/src/testing/define-api.d.ts.map +1 -0
- package/dist/src/testing/define-api.js +81 -0
- package/dist/src/testing/define-api.js.map +1 -0
- package/dist/src/testing/helpers.d.ts +55 -0
- package/dist/src/testing/helpers.d.ts.map +1 -0
- package/dist/src/testing/helpers.js +182 -0
- package/dist/src/testing/helpers.js.map +1 -0
- package/dist/src/testing/phoenix-test-tracking.d.ts +68 -0
- package/dist/src/testing/phoenix-test-tracking.d.ts.map +1 -0
- package/dist/src/testing/phoenix-test-tracking.js +530 -0
- package/dist/src/testing/phoenix-test-tracking.js.map +1 -0
- package/dist/src/testing/report-artifacts.d.ts +45 -0
- package/dist/src/testing/report-artifacts.d.ts.map +1 -0
- package/dist/src/testing/report-artifacts.js +225 -0
- package/dist/src/testing/report-artifacts.js.map +1 -0
- package/dist/src/testing/report-run.d.ts +22 -0
- package/dist/src/testing/report-run.d.ts.map +1 -0
- package/dist/src/testing/report-run.js +47 -0
- package/dist/src/testing/report-run.js.map +1 -0
- package/dist/src/testing/reporter-format.d.ts +83 -0
- package/dist/src/testing/reporter-format.d.ts.map +1 -0
- package/dist/src/testing/reporter-format.js +870 -0
- package/dist/src/testing/reporter-format.js.map +1 -0
- package/dist/src/testing/runner.d.ts +31 -0
- package/dist/src/testing/runner.d.ts.map +1 -0
- package/dist/src/testing/runner.js +258 -0
- package/dist/src/testing/runner.js.map +1 -0
- package/dist/src/testing/state.d.ts +138 -0
- package/dist/src/testing/state.d.ts.map +1 -0
- package/dist/src/testing/state.js +38 -0
- package/dist/src/testing/state.js.map +1 -0
- package/dist/src/testing/types.d.ts +319 -0
- package/dist/src/testing/types.d.ts.map +1 -0
- package/dist/src/testing/types.js +13 -0
- package/dist/src/testing/types.js.map +1 -0
- package/dist/src/utils/channel.d.ts +7 -7
- package/dist/src/utils/channel.d.ts.map +1 -1
- package/dist/src/utils/channel.js +1 -1
- package/dist/src/utils/channel.js.map +1 -1
- package/dist/src/utils/formatPromptMessages.d.ts.map +1 -1
- package/dist/src/utils/getPromptBySelector.d.ts.map +1 -1
- package/dist/src/utils/promisifyResult.d.ts +1 -1
- package/dist/src/utils/promisifyResult.d.ts.map +1 -1
- package/dist/src/utils/promisifyResult.js.map +1 -1
- package/dist/src/utils/schemaMatches.d.ts +5 -5
- package/dist/src/utils/schemaMatches.d.ts.map +1 -1
- package/dist/src/utils/schemaMatches.js.map +1 -1
- package/dist/src/vitest/index.d.ts +5 -0
- package/dist/src/vitest/index.d.ts.map +1 -0
- package/dist/src/vitest/index.js +23 -0
- package/dist/src/vitest/index.js.map +1 -0
- package/dist/src/vitest/reporter.d.ts +19 -0
- package/dist/src/vitest/reporter.d.ts.map +1 -0
- package/dist/src/vitest/reporter.js +34 -0
- package/dist/src/vitest/reporter.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/ci-evals-annotations.mdx +190 -0
- package/docs/ci-evals-jest.mdx +78 -0
- package/docs/ci-evals-vitest.mdx +240 -0
- package/docs/ci-evals.mdx +263 -0
- package/docs/overview.mdx +9 -1
- package/package.json +49 -17
- package/src/__generated__/api/v1.ts +254 -2
- package/src/jest/index.ts +124 -0
- package/src/jest/reporter.ts +22 -0
- package/src/prompts/sdks/toAI.ts +4 -3
- package/src/prompts/sdks/toAnthropic.ts +4 -3
- package/src/prompts/sdks/toOpenAI.ts +4 -3
- package/src/prompts/sdks/toSDK.ts +16 -11
- package/src/prompts/sdks/types.ts +2 -2
- package/src/testing/acceptance.ts +190 -0
- package/src/testing/define-api.ts +279 -0
- package/src/testing/helpers.ts +251 -0
- package/src/testing/phoenix-test-tracking.ts +637 -0
- package/src/testing/report-artifacts.ts +272 -0
- package/src/testing/report-run.ts +44 -0
- package/src/testing/reporter-format.ts +1072 -0
- package/src/testing/runner.ts +350 -0
- package/src/testing/state.ts +165 -0
- package/src/testing/types.ts +366 -0
- package/src/utils/channel.ts +17 -15
- package/src/utils/promisifyResult.ts +6 -4
- package/src/utils/schemaMatches.ts +12 -10
- package/src/vitest/index.ts +57 -0
- package/src/vitest/reporter.ts +32 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attachGlobalTracerProvider,
|
|
3
|
+
context,
|
|
4
|
+
createNoOpProvider,
|
|
5
|
+
type GlobalTracerProviderRegistration,
|
|
6
|
+
MimeType,
|
|
7
|
+
type NodeTracerProvider,
|
|
8
|
+
OpenInferenceSpanKind,
|
|
9
|
+
register,
|
|
10
|
+
SemanticConventions,
|
|
11
|
+
SpanStatusCode,
|
|
12
|
+
suppressTracing,
|
|
13
|
+
trace,
|
|
14
|
+
type Tracer,
|
|
15
|
+
} from "@arizeai/phoenix-otel";
|
|
16
|
+
import type { Span } from "@opentelemetry/api";
|
|
17
|
+
|
|
18
|
+
import { createDataset } from "../datasets";
|
|
19
|
+
import { cleanupOwnedTracerProvider } from "../experiments/tracing";
|
|
20
|
+
import { createClient, type PhoenixClient } from "../index";
|
|
21
|
+
import { ensureString } from "../utils/ensureString";
|
|
22
|
+
import { toObjectHeaders } from "../utils/toObjectHeaders";
|
|
23
|
+
import {
|
|
24
|
+
getDatasetExperimentsUrl,
|
|
25
|
+
getDatasetUrl,
|
|
26
|
+
getExperimentUrl,
|
|
27
|
+
} from "../utils/urlUtils";
|
|
28
|
+
import { currentRun, type RunState, type SuiteState } from "./state";
|
|
29
|
+
import type { Annotation, KVMap } from "./types";
|
|
30
|
+
|
|
31
|
+
function isFalsyFlag(value: string | undefined): boolean {
|
|
32
|
+
const v = (value ?? "").toLowerCase();
|
|
33
|
+
return v === "false" || v === "0" || v === "off" || v === "no";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether tests should sync to Phoenix.
|
|
38
|
+
*
|
|
39
|
+
* Tracing is enabled by default. It can be disabled globally by setting
|
|
40
|
+
* `PHOENIX_TEST_TRACKING=false`, or per suite via `SuiteConfig.dryRun`.
|
|
41
|
+
*/
|
|
42
|
+
export function isTrackingEnabled(suite?: SuiteState): {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
} {
|
|
46
|
+
if (isFalsyFlag(process.env.PHOENIX_TEST_TRACKING)) {
|
|
47
|
+
return { enabled: false, reason: "PHOENIX_TEST_TRACKING is disabled" };
|
|
48
|
+
}
|
|
49
|
+
if (suite?.config.dryRun) {
|
|
50
|
+
return { enabled: false, reason: "suite configured dryRun" };
|
|
51
|
+
}
|
|
52
|
+
return { enabled: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the repetition count for a test: per-test value, else suite-level,
|
|
57
|
+
* else the `PHOENIX_TEST_REPETITIONS` env var, else `1`. Non-positive or
|
|
58
|
+
* non-finite values fall back to `1`.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveRepetitions(
|
|
61
|
+
perTest: number | undefined,
|
|
62
|
+
suite: SuiteState
|
|
63
|
+
): number {
|
|
64
|
+
const envValue = Number(process.env.PHOENIX_TEST_REPETITIONS);
|
|
65
|
+
const candidates = [
|
|
66
|
+
perTest,
|
|
67
|
+
suite.config.repetitions,
|
|
68
|
+
Number.isFinite(envValue) ? envValue : undefined,
|
|
69
|
+
];
|
|
70
|
+
for (const c of candidates) {
|
|
71
|
+
if (typeof c === "number" && Number.isFinite(c) && c >= 1) {
|
|
72
|
+
return Math.floor(c);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deterministic key for matching dataset examples by content. Uses sorted
|
|
80
|
+
* keys so two inputs that differ only in property order hash the same.
|
|
81
|
+
*/
|
|
82
|
+
function stableKey(value: unknown): string {
|
|
83
|
+
return stableStringify(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stableStringify(value: unknown): string {
|
|
87
|
+
if (value === null || typeof value !== "object") {
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
92
|
+
}
|
|
93
|
+
const keys = Object.keys(value as object).sort();
|
|
94
|
+
return (
|
|
95
|
+
"{" +
|
|
96
|
+
keys
|
|
97
|
+
.map(
|
|
98
|
+
(k) =>
|
|
99
|
+
JSON.stringify(k) +
|
|
100
|
+
":" +
|
|
101
|
+
stableStringify((value as Record<string, unknown>)[k])
|
|
102
|
+
)
|
|
103
|
+
.join(",") +
|
|
104
|
+
"}"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** TEXT for raw strings, JSON for everything else. */
|
|
109
|
+
function mimeTypeFor(value: unknown): MimeType {
|
|
110
|
+
return typeof value === "string" ? MimeType.TEXT : MimeType.JSON;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface TaskSpanLifecycle {
|
|
114
|
+
span: Span;
|
|
115
|
+
traceId: string;
|
|
116
|
+
ended: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const taskSpansByRun = new WeakMap<RunState, TaskSpanLifecycle>();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Warn once when `PHOENIX_HOST` is plain `http:` while an `Authorization`
|
|
123
|
+
* header is being forwarded to the OTLP exporter — that combination
|
|
124
|
+
* exfiltrates the bearer token in cleartext.
|
|
125
|
+
*/
|
|
126
|
+
let warnedAboutHttpScheme = false;
|
|
127
|
+
function maybeWarnHttpScheme(
|
|
128
|
+
baseUrl: string | undefined,
|
|
129
|
+
headers: PhoenixClient["config"]["headers"]
|
|
130
|
+
): void {
|
|
131
|
+
if (warnedAboutHttpScheme || !baseUrl) return;
|
|
132
|
+
let parsed: URL;
|
|
133
|
+
try {
|
|
134
|
+
parsed = new URL(baseUrl);
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (parsed.protocol !== "http:") return;
|
|
139
|
+
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1")
|
|
140
|
+
return;
|
|
141
|
+
if (!headers) return;
|
|
142
|
+
const picked = toObjectHeaders(headers);
|
|
143
|
+
const hasAuth = Object.keys(picked).some(
|
|
144
|
+
(h) => h.toLowerCase() === "authorization"
|
|
145
|
+
);
|
|
146
|
+
if (!hasAuth) return;
|
|
147
|
+
warnedAboutHttpScheme = true;
|
|
148
|
+
// eslint-disable-next-line no-console
|
|
149
|
+
console.warn(
|
|
150
|
+
`[@arizeai/phoenix-client] PHOENIX_HOST="${baseUrl}" uses http:// with ` +
|
|
151
|
+
`an Authorization header set; the bearer token will travel in cleartext. ` +
|
|
152
|
+
`Use https:// for non-localhost Phoenix endpoints.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildLinks(
|
|
157
|
+
client: PhoenixClient,
|
|
158
|
+
datasetId: string,
|
|
159
|
+
experimentId: string
|
|
160
|
+
): Array<{ label: string; url: string }> {
|
|
161
|
+
const baseUrl = client.config.baseUrl;
|
|
162
|
+
if (!baseUrl) return [];
|
|
163
|
+
return [
|
|
164
|
+
{ label: "Dataset", url: getDatasetUrl({ baseUrl, datasetId }) },
|
|
165
|
+
{
|
|
166
|
+
label: "Experiments",
|
|
167
|
+
url: getDatasetExperimentsUrl({ baseUrl, datasetId }),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
label: "Experiment",
|
|
171
|
+
url: getExperimentUrl({ baseUrl, datasetId, experimentId }),
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Initialize the suite: upload the dataset, create the experiment, and
|
|
178
|
+
* register the OpenInference tracer.
|
|
179
|
+
*
|
|
180
|
+
* If tracing is disabled (no Phoenix env vars, or PHOENIX_TEST_TRACKING=false),
|
|
181
|
+
* this populates a no-op tracer and exits without making any network calls.
|
|
182
|
+
*/
|
|
183
|
+
export async function initializeSuite(suite: SuiteState): Promise<void> {
|
|
184
|
+
const tracking = isTrackingEnabled(suite);
|
|
185
|
+
if (!tracking.enabled) {
|
|
186
|
+
suite.trackingDisabled = true;
|
|
187
|
+
suite.trackingDisabledReason = tracking.reason;
|
|
188
|
+
suite.tracer = createNoOpProvider().getTracer("no-op");
|
|
189
|
+
suite.evaluatorTracer = suite.tracer;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const client = suite.config.client ?? createClient();
|
|
194
|
+
suite.client = client;
|
|
195
|
+
|
|
196
|
+
const datasetName = suite.config.datasetName ?? suite.name;
|
|
197
|
+
const description =
|
|
198
|
+
suite.config.description ??
|
|
199
|
+
`Phoenix test dataset auto-generated from ${suite.name}`;
|
|
200
|
+
|
|
201
|
+
const examples = Array.from(suite.registeredExamples.values()).map(
|
|
202
|
+
(registered) => ({
|
|
203
|
+
id: registered.params.id ?? null,
|
|
204
|
+
input: registered.params.input,
|
|
205
|
+
output: registered.params.expected ?? {},
|
|
206
|
+
metadata: registered.params.metadata ?? {},
|
|
207
|
+
splits: registered.params.splits,
|
|
208
|
+
})
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
let datasetId: string;
|
|
212
|
+
try {
|
|
213
|
+
const created = await createDataset({
|
|
214
|
+
client,
|
|
215
|
+
name: datasetName,
|
|
216
|
+
description,
|
|
217
|
+
examples,
|
|
218
|
+
});
|
|
219
|
+
datasetId = created.datasetId;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
suite.trackingDisabled = true;
|
|
222
|
+
suite.setupError = err instanceof Error ? err : new Error(String(err));
|
|
223
|
+
suite.tracer = createNoOpProvider().getTracer("no-op");
|
|
224
|
+
suite.evaluatorTracer = suite.tracer;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
suite.datasetId = datasetId;
|
|
228
|
+
|
|
229
|
+
// Map test names to server-side example ids by re-fetching the dataset.
|
|
230
|
+
// The server doesn't promise that the GET response order matches the
|
|
231
|
+
// upload order, so we match by user-supplied `id` first, then by
|
|
232
|
+
// `JSON.stringify(input)` deep-equality with FIFO-on-collision.
|
|
233
|
+
try {
|
|
234
|
+
const { data: response } = await client.GET("/v1/datasets/{id}/examples", {
|
|
235
|
+
params: { path: { id: datasetId } },
|
|
236
|
+
});
|
|
237
|
+
const fetched = response?.data?.examples ?? [];
|
|
238
|
+
|
|
239
|
+
const idToTestName = new Map<string, string>();
|
|
240
|
+
const inputKeyToTestNames = new Map<string, string[]>();
|
|
241
|
+
for (const [testName, registered] of suite.registeredExamples.entries()) {
|
|
242
|
+
if (registered.params.id) {
|
|
243
|
+
idToTestName.set(registered.params.id, testName);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const key = stableKey(registered.params.input);
|
|
247
|
+
const arr = inputKeyToTestNames.get(key) ?? [];
|
|
248
|
+
arr.push(testName);
|
|
249
|
+
inputKeyToTestNames.set(key, arr);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const ex of fetched) {
|
|
253
|
+
const byId = idToTestName.get(ex.id);
|
|
254
|
+
if (byId) {
|
|
255
|
+
suite.exampleIdsByTest.set(byId, {
|
|
256
|
+
exampleId: ex.id,
|
|
257
|
+
nodeId: ex.node_id,
|
|
258
|
+
});
|
|
259
|
+
idToTestName.delete(ex.id);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const queue = inputKeyToTestNames.get(stableKey(ex.input));
|
|
263
|
+
if (queue && queue.length) {
|
|
264
|
+
const testName = queue.shift() as string;
|
|
265
|
+
suite.exampleIdsByTest.set(testName, {
|
|
266
|
+
exampleId: ex.id,
|
|
267
|
+
nodeId: ex.node_id,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// If we cannot resolve example ids, runs will be logged without one.
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const projectName = `${datasetName}-${new Date().toISOString()}`;
|
|
276
|
+
suite.projectName = projectName;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const experimentResponse = await client
|
|
280
|
+
.POST("/v1/datasets/{dataset_id}/experiments", {
|
|
281
|
+
params: { path: { dataset_id: datasetId } },
|
|
282
|
+
body: {
|
|
283
|
+
name: suite.config.datasetName ?? suite.name,
|
|
284
|
+
description,
|
|
285
|
+
metadata: { ...(suite.config.metadata ?? {}), ...envMetadata() },
|
|
286
|
+
project_name: projectName,
|
|
287
|
+
repetitions: Math.max(1, suite.maxRepetitions ?? 1),
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
.then((res) => res.data?.data);
|
|
291
|
+
if (!experimentResponse) {
|
|
292
|
+
throw new Error("Failed to create experiment");
|
|
293
|
+
}
|
|
294
|
+
suite.experimentId = experimentResponse.id;
|
|
295
|
+
suite.projectName = experimentResponse.project_name ?? projectName;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
suite.trackingDisabled = true;
|
|
298
|
+
suite.setupError = err instanceof Error ? err : new Error(String(err));
|
|
299
|
+
suite.tracer = createNoOpProvider().getTracer("no-op");
|
|
300
|
+
suite.evaluatorTracer = suite.tracer;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const baseUrl = client.config.baseUrl;
|
|
305
|
+
if (!baseUrl) {
|
|
306
|
+
suite.trackingDisabled = true;
|
|
307
|
+
suite.setupError = new Error(
|
|
308
|
+
"Phoenix base URL not found. Set PHOENIX_HOST or pass baseUrl on the client."
|
|
309
|
+
);
|
|
310
|
+
suite.tracer = createNoOpProvider().getTracer("no-op");
|
|
311
|
+
suite.evaluatorTracer = suite.tracer;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
maybeWarnHttpScheme(baseUrl, client.config.headers);
|
|
316
|
+
|
|
317
|
+
let provider: NodeTracerProvider;
|
|
318
|
+
try {
|
|
319
|
+
provider = register({
|
|
320
|
+
projectName: suite.projectName,
|
|
321
|
+
url: baseUrl,
|
|
322
|
+
headers: client.config.headers
|
|
323
|
+
? toObjectHeaders(client.config.headers)
|
|
324
|
+
: undefined,
|
|
325
|
+
batch: false,
|
|
326
|
+
global: false,
|
|
327
|
+
});
|
|
328
|
+
suite.tracerProvider = provider;
|
|
329
|
+
suite.globalRegistration = attachGlobalTracerProvider(provider);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
suite.trackingDisabled = true;
|
|
332
|
+
suite.setupError = err instanceof Error ? err : new Error(String(err));
|
|
333
|
+
suite.tracer = createNoOpProvider().getTracer("no-op");
|
|
334
|
+
suite.evaluatorTracer = suite.tracer;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
suite.tracer = provider.getTracer(suite.projectName);
|
|
338
|
+
suite.evaluatorTracer = provider.getTracer(`${suite.projectName}-evaluators`);
|
|
339
|
+
|
|
340
|
+
if (suite.datasetId && suite.experimentId) {
|
|
341
|
+
suite.links = buildLinks(client, suite.datasetId, suite.experimentId);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Lazily-created no-op tracer reused for dry runs / fallbacks. */
|
|
346
|
+
let noOpTracer: Tracer | undefined;
|
|
347
|
+
function getNoOpTracer(): Tracer {
|
|
348
|
+
if (!noOpTracer) noOpTracer = createNoOpProvider().getTracer("no-op");
|
|
349
|
+
return noOpTracer;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The tracer to use for the currently-running test — no-op when dry. */
|
|
353
|
+
function taskTracer(suite: SuiteState): Tracer {
|
|
354
|
+
if (currentRun()?.dryRun) return getNoOpTracer();
|
|
355
|
+
return suite.tracer ?? getNoOpTracer();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Wrap the user's test body in an OpenInference task span and return the
|
|
360
|
+
* trace id so we can submit it with the experiment run.
|
|
361
|
+
*/
|
|
362
|
+
export async function runTaskWithTracing<Result>(
|
|
363
|
+
suite: SuiteState,
|
|
364
|
+
testName: string,
|
|
365
|
+
fn: () => Promise<Result>
|
|
366
|
+
): Promise<
|
|
367
|
+
| { traceId: string; result: Result }
|
|
368
|
+
| { traceId: string; error: Error; isTaskError: boolean }
|
|
369
|
+
> {
|
|
370
|
+
const tracer: Tracer = taskTracer(suite);
|
|
371
|
+
return tracer.startActiveSpan(`Test: ${testName}`, async (span) => {
|
|
372
|
+
const traceId = span.spanContext().traceId;
|
|
373
|
+
const run = currentRun();
|
|
374
|
+
if (run) {
|
|
375
|
+
run.traceId = traceId;
|
|
376
|
+
taskSpansByRun.set(run, { span, traceId, ended: false });
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const result = await fn();
|
|
380
|
+
if (run) {
|
|
381
|
+
endTaskSpan({ run, fallbackOutput: result });
|
|
382
|
+
} else {
|
|
383
|
+
endSpanAsTask({ span, input: undefined, output: result });
|
|
384
|
+
}
|
|
385
|
+
return { traceId, result };
|
|
386
|
+
} catch (err) {
|
|
387
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
388
|
+
const isTaskError = run ? !hasTaskSpanEnded(run) : true;
|
|
389
|
+
if (run && isTaskError) {
|
|
390
|
+
endTaskSpan({ run, error });
|
|
391
|
+
} else if (!run) {
|
|
392
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
393
|
+
span.end();
|
|
394
|
+
}
|
|
395
|
+
return { traceId, error, isTaskError };
|
|
396
|
+
} finally {
|
|
397
|
+
if (run) {
|
|
398
|
+
taskSpansByRun.delete(run);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function hasTaskSpanEnded(run: RunState): boolean {
|
|
405
|
+
return taskSpansByRun.get(run)?.ended ?? false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* End the current run's task span. `logOutput()` calls this immediately so
|
|
410
|
+
* evaluator work that follows is not included in the task span duration.
|
|
411
|
+
*/
|
|
412
|
+
export function endTaskSpanForRun(run: RunState): void {
|
|
413
|
+
endTaskSpan({ run });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function endTaskSpan({
|
|
417
|
+
run,
|
|
418
|
+
fallbackOutput,
|
|
419
|
+
error,
|
|
420
|
+
}: {
|
|
421
|
+
run: RunState;
|
|
422
|
+
fallbackOutput?: unknown;
|
|
423
|
+
error?: Error;
|
|
424
|
+
}): void {
|
|
425
|
+
const lifecycle = taskSpansByRun.get(run);
|
|
426
|
+
if (!lifecycle || lifecycle.ended) return;
|
|
427
|
+
const output = run.outputSet ? run.output : fallbackOutput;
|
|
428
|
+
if (error) {
|
|
429
|
+
lifecycle.span.setStatus({
|
|
430
|
+
code: SpanStatusCode.ERROR,
|
|
431
|
+
message: error.message,
|
|
432
|
+
});
|
|
433
|
+
lifecycle.span.setAttributes({
|
|
434
|
+
[SemanticConventions.OPENINFERENCE_SPAN_KIND]:
|
|
435
|
+
OpenInferenceSpanKind.CHAIN,
|
|
436
|
+
[SemanticConventions.INPUT_MIME_TYPE]: mimeTypeFor(run.params.input),
|
|
437
|
+
[SemanticConventions.INPUT_VALUE]: ensureString(run.params.input),
|
|
438
|
+
});
|
|
439
|
+
lifecycle.span.end();
|
|
440
|
+
} else {
|
|
441
|
+
endSpanAsTask({ span: lifecycle.span, input: run.params.input, output });
|
|
442
|
+
}
|
|
443
|
+
run.traceId = lifecycle.traceId;
|
|
444
|
+
run.taskEndTime = new Date();
|
|
445
|
+
lifecycle.ended = true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function endSpanAsTask({
|
|
449
|
+
span,
|
|
450
|
+
input,
|
|
451
|
+
output,
|
|
452
|
+
}: {
|
|
453
|
+
span: Span;
|
|
454
|
+
input: unknown;
|
|
455
|
+
output: unknown;
|
|
456
|
+
}): void {
|
|
457
|
+
span.setAttributes({
|
|
458
|
+
[SemanticConventions.OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.CHAIN,
|
|
459
|
+
[SemanticConventions.INPUT_MIME_TYPE]: mimeTypeFor(input),
|
|
460
|
+
[SemanticConventions.INPUT_VALUE]: ensureString(input),
|
|
461
|
+
[SemanticConventions.OUTPUT_MIME_TYPE]: mimeTypeFor(output),
|
|
462
|
+
[SemanticConventions.OUTPUT_VALUE]: ensureString(output),
|
|
463
|
+
});
|
|
464
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
465
|
+
span.end();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* POST a single experiment run for one test case to Phoenix.
|
|
470
|
+
*
|
|
471
|
+
* Best-effort: failures are captured and surfaced via the test reporter but
|
|
472
|
+
* do not fail the test itself.
|
|
473
|
+
*/
|
|
474
|
+
export async function postExperimentRun(
|
|
475
|
+
suite: SuiteState,
|
|
476
|
+
run: RunState
|
|
477
|
+
): Promise<string | undefined> {
|
|
478
|
+
if (
|
|
479
|
+
run.dryRun ||
|
|
480
|
+
suite.trackingDisabled ||
|
|
481
|
+
!suite.client ||
|
|
482
|
+
!suite.experimentId
|
|
483
|
+
) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
const example = suite.exampleIdsByTest.get(run.logicalName);
|
|
487
|
+
if (!example) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const res = await suite.client.POST(
|
|
492
|
+
"/v1/experiments/{experiment_id}/runs",
|
|
493
|
+
{
|
|
494
|
+
params: { path: { experiment_id: suite.experimentId } },
|
|
495
|
+
body: {
|
|
496
|
+
dataset_example_id: example.nodeId,
|
|
497
|
+
output: run.outputSet
|
|
498
|
+
? (run.output as Record<string, unknown> | string | null)
|
|
499
|
+
: null,
|
|
500
|
+
repetition_number: run.repetitionNumber,
|
|
501
|
+
start_time: run.startTime.toISOString(),
|
|
502
|
+
end_time: (
|
|
503
|
+
run.taskEndTime ??
|
|
504
|
+
run.endTime ??
|
|
505
|
+
new Date()
|
|
506
|
+
).toISOString(),
|
|
507
|
+
error: run.error ?? null,
|
|
508
|
+
trace_id: run.traceId ?? null,
|
|
509
|
+
},
|
|
510
|
+
}
|
|
511
|
+
);
|
|
512
|
+
return res.data?.data.id;
|
|
513
|
+
} catch {
|
|
514
|
+
suite.uploadFailureCount = (suite.uploadFailureCount ?? 0) + 1;
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* POST one annotation (an "experiment_evaluation") for a run.
|
|
521
|
+
*/
|
|
522
|
+
export async function postAnnotation(
|
|
523
|
+
suite: SuiteState,
|
|
524
|
+
runId: string | undefined,
|
|
525
|
+
annotation: Annotation
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
if (suite.trackingDisabled || !suite.client || !runId) return;
|
|
528
|
+
const start = new Date();
|
|
529
|
+
const end = new Date();
|
|
530
|
+
try {
|
|
531
|
+
await suite.client.POST("/v1/experiment_evaluations", {
|
|
532
|
+
body: {
|
|
533
|
+
experiment_run_id: runId,
|
|
534
|
+
name: annotation.name,
|
|
535
|
+
annotator_kind: annotation.annotatorKind ?? "CODE",
|
|
536
|
+
start_time: start.toISOString(),
|
|
537
|
+
end_time: end.toISOString(),
|
|
538
|
+
result: {
|
|
539
|
+
score:
|
|
540
|
+
typeof annotation.score === "boolean"
|
|
541
|
+
? annotation.score
|
|
542
|
+
? 1
|
|
543
|
+
: 0
|
|
544
|
+
: (annotation.score ?? null),
|
|
545
|
+
label: annotation.label ?? null,
|
|
546
|
+
explanation: annotation.explanation ?? null,
|
|
547
|
+
// Forward annotation metadata, matching the experiment evaluator
|
|
548
|
+
// upload path in `runExperiment`. Spread so it's omitted when unset.
|
|
549
|
+
...(annotation.metadata ? { metadata: annotation.metadata } : {}),
|
|
550
|
+
},
|
|
551
|
+
error: null,
|
|
552
|
+
trace_id: annotation.traceId ?? null,
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
} catch {
|
|
556
|
+
suite.uploadFailureCount = (suite.uploadFailureCount ?? 0) + 1;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Run an evaluator in an OpenInference evaluator span. */
|
|
561
|
+
export async function runEvaluatorWithTracing<
|
|
562
|
+
EvaluatorParams extends KVMap,
|
|
563
|
+
EvaluatorResult,
|
|
564
|
+
>(
|
|
565
|
+
suite: SuiteState,
|
|
566
|
+
name: string,
|
|
567
|
+
params: EvaluatorParams,
|
|
568
|
+
fn: (params: EvaluatorParams) => EvaluatorResult | Promise<EvaluatorResult>
|
|
569
|
+
): Promise<{ result: EvaluatorResult; traceId: string | null }> {
|
|
570
|
+
const isDryRun = currentRun()?.dryRun ?? false;
|
|
571
|
+
const tracer: Tracer = isDryRun
|
|
572
|
+
? getNoOpTracer()
|
|
573
|
+
: (suite.evaluatorTracer ?? suite.tracer ?? getNoOpTracer());
|
|
574
|
+
const parentlessContext = trace.deleteSpan(context.active());
|
|
575
|
+
const evaluatorContext = isDryRun
|
|
576
|
+
? suppressTracing(parentlessContext)
|
|
577
|
+
: parentlessContext;
|
|
578
|
+
return context.with(evaluatorContext, () =>
|
|
579
|
+
tracer.startActiveSpan(`Evaluation: ${name}`, async (span) => {
|
|
580
|
+
const traceId = isDryRun ? null : span.spanContext().traceId;
|
|
581
|
+
try {
|
|
582
|
+
const result = await fn(params);
|
|
583
|
+
span.setAttributes({
|
|
584
|
+
[SemanticConventions.OPENINFERENCE_SPAN_KIND]:
|
|
585
|
+
OpenInferenceSpanKind.EVALUATOR,
|
|
586
|
+
[SemanticConventions.INPUT_MIME_TYPE]: MimeType.JSON,
|
|
587
|
+
[SemanticConventions.INPUT_VALUE]: ensureString(params),
|
|
588
|
+
[SemanticConventions.OUTPUT_MIME_TYPE]: MimeType.JSON,
|
|
589
|
+
[SemanticConventions.OUTPUT_VALUE]: ensureString(result),
|
|
590
|
+
});
|
|
591
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
592
|
+
return { result, traceId };
|
|
593
|
+
} catch (err) {
|
|
594
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
595
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
596
|
+
throw error;
|
|
597
|
+
} finally {
|
|
598
|
+
span.end();
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Tear down the tracer provider created for the suite, flushing any pending
|
|
606
|
+
* spans before the process exits.
|
|
607
|
+
*/
|
|
608
|
+
export async function teardownSuite(suite: SuiteState): Promise<void> {
|
|
609
|
+
const provider = suite.tracerProvider;
|
|
610
|
+
if (!provider) return;
|
|
611
|
+
try {
|
|
612
|
+
await cleanupOwnedTracerProvider({
|
|
613
|
+
provider,
|
|
614
|
+
globalRegistration: suite.globalRegistration,
|
|
615
|
+
});
|
|
616
|
+
} finally {
|
|
617
|
+
suite.tracerProvider = undefined;
|
|
618
|
+
suite.globalRegistration = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Snapshot environment-derived metadata recorded on the experiment so users
|
|
624
|
+
* can filter experiments by env in the Phoenix UI.
|
|
625
|
+
*/
|
|
626
|
+
function envMetadata(): KVMap {
|
|
627
|
+
const out: KVMap = {};
|
|
628
|
+
const env =
|
|
629
|
+
process.env.PHOENIX_ENVIRONMENT ??
|
|
630
|
+
process.env.ENVIRONMENT ??
|
|
631
|
+
process.env.NODE_ENV;
|
|
632
|
+
if (env) out.environment = env;
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Re-export the ones the entrypoint files consume. */
|
|
637
|
+
export type { GlobalTracerProviderRegistration, NodeTracerProvider };
|