@cortad/connector 0.6.0-rc.1 → 0.6.0-rc.2
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/lib/history/braintrust.mjs +83 -0
- package/lib/history/helicone.mjs +59 -0
- package/lib/history/langsmith.mjs +62 -0
- package/lib/history/opik.mjs +42 -0
- package/lib/history/phoenix.mjs +58 -0
- package/lib/history/vendor.mjs +68 -0
- package/lib/intelligence.mjs +6 -1
- package/package.json +9 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { said } from "./langfuse.mjs";
|
|
2
|
+
import { answered, grouped, msOf, rootOf, threadIn, userIn } from "./vendor.mjs";
|
|
3
|
+
|
|
4
|
+
// The app's recent conversations as Braintrust logged them, read with the key the app already has,
|
|
5
|
+
// sent to Braintrust's own API and nowhere else. The project comes from GET /v1/project, its logs
|
|
6
|
+
// from POST /v1/project_logs/{project_id}/fetch with the cursor each page hands back (Braintrust API
|
|
7
|
+
// reference, "Fetch project logs"). A trace's root span is one reply.
|
|
8
|
+
const PAGE = 100;
|
|
9
|
+
const MAX_TRACES = 3_000;
|
|
10
|
+
|
|
11
|
+
export const BRAINTRUST = {
|
|
12
|
+
id: "braintrust",
|
|
13
|
+
name: "Braintrust",
|
|
14
|
+
needs: ["BRAINTRUST_API_KEY"],
|
|
15
|
+
hostOf: (env) => env.BRAINTRUST_API_URL || "https://api.braintrust.dev",
|
|
16
|
+
read: readBraintrust,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// The project to read: BRAINTRUST_PARENT as Braintrust's OpenTelemetry exporter takes it, else
|
|
20
|
+
// BRAINTRUST_PROJECT. An app that names its project in code has neither.
|
|
21
|
+
export function projectIn(env) {
|
|
22
|
+
const [kind, ...rest] = (env.BRAINTRUST_PARENT ?? "").split(":");
|
|
23
|
+
const value = rest.join(":");
|
|
24
|
+
if (kind === "project_id" && value) return { id: value };
|
|
25
|
+
if (kind === "project_name" && value) return { name: value };
|
|
26
|
+
return env.BRAINTRUST_PROJECT ? { name: env.BRAINTRUST_PROJECT } : {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isRoot = (event) => event.is_root ?? !event.span_parents?.length;
|
|
30
|
+
|
|
31
|
+
// Braintrust's OpenAI wrapper logs a reply as the completion's choices.
|
|
32
|
+
const replyOf = (output) => (Array.isArray(output) ? output.map((choice) => choice?.message ?? choice) : output);
|
|
33
|
+
|
|
34
|
+
export async function readBraintrust({ env, since, fetchImpl = fetch }) {
|
|
35
|
+
const root = rootOf(BRAINTRUST.hostOf(env));
|
|
36
|
+
const host = new URL(root).host;
|
|
37
|
+
const headers = { authorization: `Bearer ${env.BRAINTRUST_API_KEY}`, "content-type": "application/json" };
|
|
38
|
+
const ask = (path, init = {}) => answered(fetchImpl, `${root}${path}`, { ...init, headers }, "BRAINTRUST");
|
|
39
|
+
const id = await projectId(ask, env, host);
|
|
40
|
+
|
|
41
|
+
const roots = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const from = since.getTime();
|
|
44
|
+
for (let cursor = null; roots.length < MAX_TRACES;) {
|
|
45
|
+
const body = JSON.stringify({ limit: PAGE, ...(cursor ? { cursor } : {}) });
|
|
46
|
+
const page = await ask(`/v1/project_logs/${encodeURIComponent(id)}/fetch`, { method: "POST", body });
|
|
47
|
+
const events = page.events ?? [];
|
|
48
|
+
// A later page can give a row again at an older version; the first seen is the newest.
|
|
49
|
+
for (const event of events) {
|
|
50
|
+
if (seen.has(event.id)) continue;
|
|
51
|
+
seen.add(event.id);
|
|
52
|
+
if (isRoot(event) && msOf(event.created) >= from) roots.push(event);
|
|
53
|
+
}
|
|
54
|
+
cursor = page.cursor;
|
|
55
|
+
// ponytail: pages run newest change first, so a page of only older rows ends the read and a bulk
|
|
56
|
+
// edit of old logs would end it early. POST /btql with a created filter is exact if that matters.
|
|
57
|
+
if (!cursor || events.every((event) => msOf(event.created) < from)) break;
|
|
58
|
+
}
|
|
59
|
+
const { conversations, withoutText } = grouped(roots.map((event) => ({
|
|
60
|
+
conversation: threadIn(event.metadata) || event.root_span_id,
|
|
61
|
+
user: userIn(event.metadata),
|
|
62
|
+
at: event.created,
|
|
63
|
+
question: said(event.input, "user"),
|
|
64
|
+
answer: said(replyOf(event.output), "assistant"),
|
|
65
|
+
})));
|
|
66
|
+
return { conversations, host, read: roots.length, withoutText };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function projectId(ask, env, host) {
|
|
70
|
+
const wanted = projectIn(env);
|
|
71
|
+
if (wanted.id) return wanted.id;
|
|
72
|
+
const org = env.BRAINTRUST_ORG_NAME ? `&org_name=${encodeURIComponent(env.BRAINTRUST_ORG_NAME)}` : "";
|
|
73
|
+
if (wanted.name) {
|
|
74
|
+
const { objects } = await ask(`/v1/project?project_name=${encodeURIComponent(wanted.name)}${org}`);
|
|
75
|
+
const project = objects?.find((p) => p.name === wanted.name);
|
|
76
|
+
if (!project) throw new Error(`${host} has no project named ${wanted.name}`);
|
|
77
|
+
return project.id;
|
|
78
|
+
}
|
|
79
|
+
// With no project named, the one project the key sees is the app's.
|
|
80
|
+
const { objects = [] } = await ask(`/v1/project?limit=2${org}`);
|
|
81
|
+
if (objects.length === 1) return objects[0].id;
|
|
82
|
+
throw new Error(objects.length ? "the key in your env sees more than one Braintrust project; set BRAINTRUST_PROJECT to the one to read" : `${host} has no project for the key in your env`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { said } from "./langfuse.mjs";
|
|
2
|
+
import { addressOf, answered, grouped, rootOf } from "./vendor.mjs";
|
|
3
|
+
|
|
4
|
+
// The app's recent conversations as Helicone logged its model calls, read with the key the app already
|
|
5
|
+
// has, sent to Helicone's own API and nowhere else. Requests come from POST
|
|
6
|
+
// /v1/request/query-clickhouse (Helicone API reference, "Get Requests"), newest first by offset. A
|
|
7
|
+
// body Helicone keeps in its store is fetched from the request's signed_body_url, with no key.
|
|
8
|
+
const PAGE = 100;
|
|
9
|
+
const MAX_REQUESTS = 3_000;
|
|
10
|
+
const NOT_A_CHAT = /\/(?:embeddings|moderations)(?:$|[/?])/;
|
|
11
|
+
|
|
12
|
+
export const HELICONE = {
|
|
13
|
+
id: "helicone",
|
|
14
|
+
name: "Helicone",
|
|
15
|
+
needs: ["HELICONE_API_KEY"],
|
|
16
|
+
// Helicone's own SDKs send a key made in the EU to the EU API.
|
|
17
|
+
hostOf: (env) => (env.HELICONE_API_KEY?.startsWith("sk-helicone-eu-") ? "https://eu.api.helicone.ai" : "https://api.helicone.ai"),
|
|
18
|
+
read: readHelicone,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A body as Helicone answers it inline: parsed JSON, or nothing when it says the body is kept elsewhere.
|
|
22
|
+
export function inline(body) {
|
|
23
|
+
let value = body;
|
|
24
|
+
if (typeof body === "string") {
|
|
25
|
+
try { value = JSON.parse(body); } catch { return body; }
|
|
26
|
+
}
|
|
27
|
+
return value && typeof value === "object" && "helicone_message" in value ? undefined : value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function bodiesOf(row, fetchImpl) {
|
|
31
|
+
if (!row.signed_body_url) return { request: inline(row.request_body), response: inline(row.response_body) };
|
|
32
|
+
const stored = await answered(fetchImpl, addressOf(row.signed_body_url).href, {}, null);
|
|
33
|
+
return { request: stored?.request, response: stored?.response };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readHelicone({ env, since, fetchImpl = fetch }) {
|
|
37
|
+
const root = rootOf(HELICONE.hostOf(env));
|
|
38
|
+
const headers = { authorization: `Bearer ${env.HELICONE_API_KEY}`, "content-type": "application/json" };
|
|
39
|
+
const filter = { request_response_rmt: { request_created_at: { gte: since.toISOString() } } };
|
|
40
|
+
const turns = [];
|
|
41
|
+
let read = 0;
|
|
42
|
+
for (let offset = 0; offset < MAX_REQUESTS; offset += PAGE) {
|
|
43
|
+
const body = JSON.stringify({ filter, offset, limit: PAGE, sort: { created_at: "desc" } });
|
|
44
|
+
const page = await answered(fetchImpl, `${root}/v1/request/query-clickhouse`, { method: "POST", headers, body }, "HELICONE");
|
|
45
|
+
const rows = page.data ?? [];
|
|
46
|
+
read += rows.length;
|
|
47
|
+
const chats = rows.filter((row) => !NOT_A_CHAT.test(row.request_path ?? ""));
|
|
48
|
+
const bodies = await Promise.all(chats.map((row) => bodiesOf(row, fetchImpl)));
|
|
49
|
+
chats.forEach((row, i) => turns.push({
|
|
50
|
+
conversation: row.request_properties?.["Helicone-Session-Id"] || row.request_id,
|
|
51
|
+
user: row.request_user_id,
|
|
52
|
+
at: row.request_created_at,
|
|
53
|
+
question: said(bodies[i].request, "user"),
|
|
54
|
+
answer: said(bodies[i].response, "assistant"),
|
|
55
|
+
}));
|
|
56
|
+
if (rows.length < PAGE) break;
|
|
57
|
+
}
|
|
58
|
+
return { ...grouped(turns), host: new URL(root).host, read };
|
|
59
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { said } from "./langfuse.mjs";
|
|
2
|
+
import { answered, grouped, rootOf, threadIn, userIn } from "./vendor.mjs";
|
|
3
|
+
|
|
4
|
+
// The app's recent conversations as LangSmith holds them, read with the key the app already has, sent
|
|
5
|
+
// to LangSmith's own address and nowhere else. The project is found by name with GET /api/v1/sessions;
|
|
6
|
+
// its root runs, one reply each, come from POST /api/v2/runs/query (LangSmith API reference, "Query
|
|
7
|
+
// runs"). v1 runs/query is deprecated and answers 501 on SmithDB deployments.
|
|
8
|
+
const PAGE = 100;
|
|
9
|
+
const MAX_RUNS = 3_000;
|
|
10
|
+
const SELECTS = ["ID", "START_TIME", "INPUTS", "OUTPUTS", "METADATA", "THREAD_ID"];
|
|
11
|
+
|
|
12
|
+
export const LANGSMITH = {
|
|
13
|
+
id: "langsmith",
|
|
14
|
+
name: "LangSmith",
|
|
15
|
+
needs: ["LANGSMITH_API_KEY"],
|
|
16
|
+
hostOf: (env) => env.LANGSMITH_ENDPOINT || env.LANGCHAIN_ENDPOINT || "https://api.smith.langchain.com",
|
|
17
|
+
read: readLangSmith,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// LangChain writes a message's role as its type, or as the class of a serialized message.
|
|
21
|
+
const ROLE_OF_TYPE = { human: "user", HumanMessage: "user", ai: "assistant", AIMessage: "assistant", AIMessageChunk: "assistant" };
|
|
22
|
+
|
|
23
|
+
export function asChat(value) {
|
|
24
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.messages)) return value;
|
|
25
|
+
return {
|
|
26
|
+
...value,
|
|
27
|
+
messages: value.messages.map((m) => {
|
|
28
|
+
if (!m || typeof m !== "object" || m.role) return m;
|
|
29
|
+
const role = ROLE_OF_TYPE[m.type === "constructor" ? m.id?.at(-1) : m.type];
|
|
30
|
+
return role ? { role, content: m.kwargs?.content ?? m.content } : m;
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readLangSmith({ env, since, fetchImpl = fetch }) {
|
|
36
|
+
// Self-hosted endpoints are given with their /api/v1; every path below starts at the root.
|
|
37
|
+
const root = rootOf(LANGSMITH.hostOf(env)).replace(/\/api(?:\/v1)?$/, "");
|
|
38
|
+
const host = new URL(root).host;
|
|
39
|
+
const headers = { "x-api-key": env.LANGSMITH_API_KEY, ...(env.LANGSMITH_WORKSPACE_ID ? { "x-tenant-id": env.LANGSMITH_WORKSPACE_ID } : {}) };
|
|
40
|
+
const ask = (path, init = {}) => answered(fetchImpl, `${root}${path}`, { ...init, headers: { ...headers, ...init.headers } }, "LANGSMITH");
|
|
41
|
+
|
|
42
|
+
const name = env.LANGSMITH_PROJECT || env.LANGCHAIN_PROJECT || "default";
|
|
43
|
+
const project = (await ask(`/api/v1/sessions?name=${encodeURIComponent(name)}`)).find((p) => p.name === name);
|
|
44
|
+
if (!project) throw new Error(`${host} has no project named ${name}`);
|
|
45
|
+
|
|
46
|
+
const runs = [];
|
|
47
|
+
for (let cursor = null; runs.length < MAX_RUNS;) {
|
|
48
|
+
const body = { project_ids: [project.id], is_root: true, min_start_time: since.toISOString(), page_size: PAGE, selects: SELECTS, ...(cursor ? { cursor } : {}) };
|
|
49
|
+
const page = await ask("/api/v2/runs/query", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
50
|
+
runs.push(...(page.items ?? []));
|
|
51
|
+
cursor = page.next_cursor;
|
|
52
|
+
if (!cursor) break;
|
|
53
|
+
}
|
|
54
|
+
const { conversations, withoutText } = grouped(runs.map((run) => ({
|
|
55
|
+
conversation: run.thread_id || threadIn(run.metadata) || run.id,
|
|
56
|
+
user: userIn(run.metadata),
|
|
57
|
+
at: run.start_time,
|
|
58
|
+
question: said(asChat(run.inputs), "user"),
|
|
59
|
+
answer: said(asChat(run.outputs), "assistant"),
|
|
60
|
+
})));
|
|
61
|
+
return { conversations, host, read: runs.length, withoutText };
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { said } from "./langfuse.mjs";
|
|
2
|
+
import { answered, grouped, rootOf, userIn } from "./vendor.mjs";
|
|
3
|
+
|
|
4
|
+
// The app's recent conversations as Opik traced them, read with the key the app already has, sent to
|
|
5
|
+
// Opik's own API and nowhere else. Traces, one reply each and newest first, come from GET
|
|
6
|
+
// /v1/private/traces by page (Opik REST API, "Get traces by project_name or project_id").
|
|
7
|
+
const PAGE = 100;
|
|
8
|
+
const MAX_TRACES = 3_000;
|
|
9
|
+
|
|
10
|
+
export const OPIK = {
|
|
11
|
+
id: "opik",
|
|
12
|
+
name: "Opik",
|
|
13
|
+
needs: ["OPIK_API_KEY", "OPIK_WORKSPACE"],
|
|
14
|
+
hostOf: (env) => env.OPIK_URL_OVERRIDE || "https://www.comet.com/opik/api",
|
|
15
|
+
read: readOpik,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Experiments, the playground, optimizers and evaluators write traces too; the app's own say "sdk".
|
|
19
|
+
const fromTheApp = (trace) => !trace.source || trace.source === "sdk";
|
|
20
|
+
|
|
21
|
+
export async function readOpik({ env, since, fetchImpl = fetch }) {
|
|
22
|
+
const root = rootOf(OPIK.hostOf(env));
|
|
23
|
+
const headers = { authorization: env.OPIK_API_KEY, "Comet-Workspace": env.OPIK_WORKSPACE };
|
|
24
|
+
const project = env.OPIK_PROJECT_NAME || "Default Project";
|
|
25
|
+
const traces = [];
|
|
26
|
+
for (let page = 1; traces.length < MAX_TRACES; page += 1) {
|
|
27
|
+
// Attachments stay references; only the text is read.
|
|
28
|
+
const query = new URLSearchParams({ project_name: project, page: String(page), size: String(PAGE), from_time: since.toISOString(), strip_attachments: "true" });
|
|
29
|
+
const body = await answered(fetchImpl, `${root}/v1/private/traces?${query}`, { headers }, "OPIK");
|
|
30
|
+
const content = body.content ?? [];
|
|
31
|
+
traces.push(...content);
|
|
32
|
+
if (content.length < PAGE) break;
|
|
33
|
+
}
|
|
34
|
+
const { conversations, withoutText } = grouped(traces.filter(fromTheApp).map((trace) => ({
|
|
35
|
+
conversation: trace.thread_id || trace.id,
|
|
36
|
+
user: userIn(trace.metadata),
|
|
37
|
+
at: trace.start_time,
|
|
38
|
+
question: said(trace.input, "user"),
|
|
39
|
+
answer: said(trace.output, "assistant"),
|
|
40
|
+
})));
|
|
41
|
+
return { conversations, host: new URL(root).host, read: traces.length, withoutText };
|
|
42
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { said } from "./langfuse.mjs";
|
|
2
|
+
import { answered, grouped, rootOf } from "./vendor.mjs";
|
|
3
|
+
|
|
4
|
+
// The app's recent conversations as Phoenix traced them, read with the key the app already has, sent
|
|
5
|
+
// to the app's own Phoenix and nowhere else. Root spans, one reply each and newest first, come from
|
|
6
|
+
// GET /v1/projects/{project_identifier}/spans with the cursor each page hands back (Phoenix REST API,
|
|
7
|
+
// "List spans with simple filters").
|
|
8
|
+
const PAGE = 100;
|
|
9
|
+
const MAX_SPANS = 3_000;
|
|
10
|
+
const OTLP_PATH = /\/+v1\/traces\/?$/;
|
|
11
|
+
|
|
12
|
+
export const PHOENIX = {
|
|
13
|
+
id: "phoenix",
|
|
14
|
+
name: "Phoenix",
|
|
15
|
+
needs: ["PHOENIX_COLLECTOR_ENDPOINT"],
|
|
16
|
+
// As Phoenix's own client finds the server: its API address, else the collector's less the OTLP path.
|
|
17
|
+
hostOf: (env) => env.PHOENIX_ENDPOINT || env.PHOENIX_COLLECTOR_ENDPOINT?.replace(OTLP_PATH, "") || "http://localhost:6006",
|
|
18
|
+
read: readPhoenix,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// OpenInference flattens a span's chat into keys such as llm.input_messages.0.message.role.
|
|
22
|
+
export function messagesIn(attributes, prefix) {
|
|
23
|
+
const byIndex = new Map();
|
|
24
|
+
for (const [key, value] of Object.entries(attributes ?? {})) {
|
|
25
|
+
const part = key.startsWith(`${prefix}.`) && /^(\d+)\.message\.(role|content)$/.exec(key.slice(prefix.length + 1));
|
|
26
|
+
if (part) byIndex.set(Number(part[1]), { ...byIndex.get(Number(part[1])), [part[2]]: value });
|
|
27
|
+
}
|
|
28
|
+
return [...byIndex].sort(([a], [b]) => a - b).map(([, message]) => message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A span's input or output: parsed when its mime type says JSON, else the text as written.
|
|
32
|
+
export function valueIn(attributes, kind) {
|
|
33
|
+
const value = attributes?.[`${kind}.value`];
|
|
34
|
+
if (attributes?.[`${kind}.mime_type`] !== "application/json" || typeof value !== "string") return value;
|
|
35
|
+
try { return JSON.parse(value); } catch { return value; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readPhoenix({ env, since, fetchImpl = fetch }) {
|
|
39
|
+
const root = rootOf(PHOENIX.hostOf(env));
|
|
40
|
+
const project = encodeURIComponent(env.PHOENIX_PROJECT || env.PHOENIX_PROJECT_NAME || "default");
|
|
41
|
+
const headers = env.PHOENIX_API_KEY ? { authorization: `Bearer ${env.PHOENIX_API_KEY}` } : {};
|
|
42
|
+
const spans = [];
|
|
43
|
+
for (let cursor = null; spans.length < MAX_SPANS;) {
|
|
44
|
+
const query = new URLSearchParams({ parent_id: "null", sort: "start_time", order: "desc", start_time: since.toISOString(), limit: String(PAGE), ...(cursor ? { cursor } : {}) });
|
|
45
|
+
const page = await answered(fetchImpl, `${root}/v1/projects/${project}/spans?${query}`, { headers }, "PHOENIX");
|
|
46
|
+
spans.push(...(page.data ?? []));
|
|
47
|
+
cursor = page.next_cursor;
|
|
48
|
+
if (!cursor) break;
|
|
49
|
+
}
|
|
50
|
+
const { conversations, withoutText } = grouped(spans.map(({ attributes: a = {}, context, start_time: at }) => ({
|
|
51
|
+
conversation: a["session.id"] || context?.trace_id,
|
|
52
|
+
user: a["user.id"],
|
|
53
|
+
at,
|
|
54
|
+
question: said(messagesIn(a, "llm.input_messages"), "user") || said(valueIn(a, "input"), "user"),
|
|
55
|
+
answer: said(messagesIn(a, "llm.output_messages"), "assistant") || said(valueIn(a, "output"), "assistant"),
|
|
56
|
+
})));
|
|
57
|
+
return { conversations, host: new URL(root).host, read: spans.length, withoutText };
|
|
58
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// What the history readers share: the vendor's address checked before a key goes to it, its JSON
|
|
2
|
+
// answer with a busy answer waited out, and its records made into conversations.
|
|
3
|
+
const LOCAL = new Set(["localhost", "127.0.0.1"]);
|
|
4
|
+
const TRIES = 4;
|
|
5
|
+
const MAX_WAIT_MS = 30_000;
|
|
6
|
+
const THREAD_KEYS = ["thread_id", "session_id", "conversation_id"];
|
|
7
|
+
|
|
8
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
9
|
+
|
|
10
|
+
// An address a vendor's key may go to: https, or http to this computer only.
|
|
11
|
+
export function addressOf(raw) {
|
|
12
|
+
const url = URL.canParse(raw) ? new URL(raw) : null;
|
|
13
|
+
if (!url || url.username || url.password) throw new Error("an address in your env is not a plain URL");
|
|
14
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL.has(url.hostname))) throw new Error(`${url.host} is not reached over https`);
|
|
15
|
+
return url;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The API's root with no trailing slash, query or fragment, for paths to join onto.
|
|
19
|
+
export function rootOf(raw) {
|
|
20
|
+
const url = addressOf(raw);
|
|
21
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The vendor's JSON. A 429 is tried again a few times, after as long as the vendor asks (up to 30
|
|
25
|
+
// seconds) or longer each time. `keys` names whose keys went with the request; a signed URL carries none.
|
|
26
|
+
export async function answered(fetchImpl, url, init, keys) {
|
|
27
|
+
const { host } = new URL(url);
|
|
28
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
29
|
+
const res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
30
|
+
if (res.status === 429 && attempt < TRIES) { await sleep(waitOf(res, attempt)); continue; }
|
|
31
|
+
if (keys && (res.status === 401 || res.status === 403)) throw new Error(`${host} refused the ${keys} keys in your env`);
|
|
32
|
+
if (!res.ok) throw new Error(`${host} answered ${res.status}`);
|
|
33
|
+
return res.json();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function waitOf(res, attempt) {
|
|
38
|
+
const seconds = Number(res.headers.get("retry-after") || Number.NaN);
|
|
39
|
+
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds * 1000, MAX_WAIT_MS) : 1000 * 2 ** (attempt - 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The conversation and the person an app named in a record's metadata, if it named them.
|
|
43
|
+
export const threadIn = (metadata) => THREAD_KEYS.map((k) => metadata?.[k]).find((v) => typeof v === "string" && v) ?? null;
|
|
44
|
+
export const userIn = (metadata) => (typeof metadata?.user_id === "string" && metadata.user_id) || null;
|
|
45
|
+
|
|
46
|
+
// A vendor's time in milliseconds. Every vendor here keeps UTC, and Helicone writes it with no zone.
|
|
47
|
+
export const msOf = (time) => Date.parse(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(time) ? time : `${String(time).replace(" ", "T")}Z`);
|
|
48
|
+
|
|
49
|
+
const isoOf = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null);
|
|
50
|
+
|
|
51
|
+
// Turns as conversations. A turn is one record: the person's newest message and the app's reply, the
|
|
52
|
+
// reply a millisecond later so the two keep their order. A turn with neither is counted, not kept.
|
|
53
|
+
export function grouped(turns) {
|
|
54
|
+
const byConversation = new Map();
|
|
55
|
+
let withoutText = 0;
|
|
56
|
+
for (const turn of turns) {
|
|
57
|
+
const question = turn.question.trim();
|
|
58
|
+
const answer = turn.answer.trim();
|
|
59
|
+
if (!question && !answer) { withoutText += 1; continue; }
|
|
60
|
+
const held = byConversation.get(turn.conversation) ?? { id: turn.conversation, messages: [] };
|
|
61
|
+
if (!held.user && turn.user) held.user = turn.user;
|
|
62
|
+
const at = msOf(turn.at);
|
|
63
|
+
if (question) held.messages.push({ role: "user", text: question, at: isoOf(at) });
|
|
64
|
+
if (answer) held.messages.push({ role: "assistant", text: answer, at: isoOf(at + 1) });
|
|
65
|
+
byConversation.set(turn.conversation, held);
|
|
66
|
+
}
|
|
67
|
+
return { conversations: [...byConversation.values()], withoutText };
|
|
68
|
+
}
|
package/lib/intelligence.mjs
CHANGED
|
@@ -8,14 +8,19 @@ import { homeOf } from "./home.mjs";
|
|
|
8
8
|
import { pair } from "./intelligence-pair.mjs";
|
|
9
9
|
import { canSet, hostOf, HOSTS, setKey } from "./hosts.mjs";
|
|
10
10
|
import { looksLikeDevelopment, selected, spanOf } from "./history/contract.mjs";
|
|
11
|
+
import { BRAINTRUST } from "./history/braintrust.mjs";
|
|
12
|
+
import { HELICONE } from "./history/helicone.mjs";
|
|
11
13
|
import { LANGFUSE } from "./history/langfuse.mjs";
|
|
14
|
+
import { LANGSMITH } from "./history/langsmith.mjs";
|
|
15
|
+
import { OPIK } from "./history/opik.mjs";
|
|
16
|
+
import { PHOENIX } from "./history/phoenix.mjs";
|
|
12
17
|
import { packed, sendAll } from "./history/send.mjs";
|
|
13
18
|
import * as say from "./intelligence-text.mjs";
|
|
14
19
|
|
|
15
20
|
// `npx cortad intelligence`: Cortad Intelligence set up from this folder, one step per run. Each run
|
|
16
21
|
// reads where things stand (Cortad's facts about this app, and this folder) and prints what is done
|
|
17
22
|
// and the one next step, with the question for the person where one is theirs. It edits no code.
|
|
18
|
-
export const SOURCES = [LANGFUSE];
|
|
23
|
+
export const SOURCES = [LANGFUSE, LANGSMITH, HELICONE, BRAINTRUST, PHOENIX, OPIK];
|
|
19
24
|
const ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local", ".dev.vars"];
|
|
20
25
|
const WAIT_MS = 9 * 60_000;
|
|
21
26
|
const POLL_MS = 5_000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortad/connector",
|
|
3
|
-
"version": "0.6.0-rc.
|
|
3
|
+
"version": "0.6.0-rc.2",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -73,10 +73,16 @@
|
|
|
73
73
|
"lib/history/contract.mjs",
|
|
74
74
|
"lib/history/send.mjs",
|
|
75
75
|
"lib/history/langfuse.mjs",
|
|
76
|
-
"lib/intelligence-pair.mjs"
|
|
76
|
+
"lib/intelligence-pair.mjs",
|
|
77
|
+
"lib/history/langsmith.mjs",
|
|
78
|
+
"lib/history/helicone.mjs",
|
|
79
|
+
"lib/history/braintrust.mjs",
|
|
80
|
+
"lib/history/phoenix.mjs",
|
|
81
|
+
"lib/history/opik.mjs",
|
|
82
|
+
"lib/history/vendor.mjs"
|
|
77
83
|
],
|
|
78
84
|
"scripts": {
|
|
79
|
-
"test": "node --test lib/*.test.mjs"
|
|
85
|
+
"test": "node --test lib/*.test.mjs lib/history/*.test.mjs"
|
|
80
86
|
},
|
|
81
87
|
"engines": {
|
|
82
88
|
"node": ">=20"
|