@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
273 lines (272 loc) • 10.7 kB
JavaScript
import { AsyncCaller } from "../utils/async_caller.js";
import { getEnvironmentVariable } from "../utils/env.js";
import { mergeSignals } from "../utils/signals.js";
import { BytesLineDecoder, SSEDecoder } from "../utils/sse.js";
import { idleReconnectStream, streamWithRetry } from "../utils/stream.js";
//#region src/client/base.ts
function* iterateHeaders(headers) {
let iter;
let shouldClear = false;
if (headers instanceof Headers) {
const entries = [];
headers.forEach((value, name) => {
entries.push([name, value]);
});
iter = entries;
} else if (Array.isArray(headers)) iter = headers;
else {
shouldClear = true;
iter = Object.entries(headers ?? {});
}
for (const item of iter) {
const name = item[0];
if (typeof name !== "string") throw new TypeError(`Expected header name to be a string, got ${typeof name}`);
const values = Array.isArray(item[1]) ? item[1] : [item[1]];
let didClear = false;
for (const value of values) {
if (value === void 0) continue;
if (shouldClear && !didClear) {
didClear = true;
yield [name, null];
}
yield [name, value];
}
}
}
function mergeHeaders(...headerObjects) {
const outputHeaders = new Headers();
for (const headers of headerObjects) {
if (!headers) continue;
for (const [name, value] of iterateHeaders(headers)) if (value === null) outputHeaders.delete(name);
else outputHeaders.append(name, value);
}
const headerEntries = [];
outputHeaders.forEach((value, name) => {
headerEntries.push([name, value]);
});
return Object.fromEntries(headerEntries);
}
/**
* Get the API key from the environment.
* Precedence:
* 1. explicit argument (if string)
* 2. LANGGRAPH_API_KEY
* 3. LANGSMITH_API_KEY
* 4. LANGCHAIN_API_KEY
*
* @param apiKey - API key provided as an argument. If null, skips environment lookup. If undefined, tries environment.
* @returns The API key if found, otherwise undefined
*/
function getApiKey(apiKey) {
if (apiKey === null) return;
if (apiKey) return apiKey;
for (const prefix of [
"LANGGRAPH",
"LANGSMITH",
"LANGCHAIN"
]) {
const envKey = getEnvironmentVariable(`${prefix}_API_KEY`);
if (envKey) return envKey.trim().replace(/^["']|["']$/g, "");
}
}
/**
* Encode an identifier as a single URL path segment so it can't change the
* request's path or query. `.` and `..` are rejected since `new URL()` would
* resolve them as dot segments.
*/
function encodePathSegment(value) {
if (value === "." || value === "..") throw new Error(`Invalid path segment: ${JSON.stringify(value)}`);
return encodeURIComponent(value);
}
var BaseClient = class {
asyncCaller;
timeoutMs;
apiUrl;
defaultHeaders;
onRequest;
streamProtocol;
constructor(config) {
const callerOptions = {
maxRetries: 4,
maxConcurrency: 4,
...config?.callerOptions
};
let defaultApiUrl = "http://localhost:8123";
if (!config?.apiUrl && typeof globalThis === "object" && globalThis != null) {
const fetchSmb = Symbol.for("langgraph_api:fetch");
const urlSmb = Symbol.for("langgraph_api:url");
const global = globalThis;
if (global[fetchSmb]) callerOptions.fetch ??= global[fetchSmb];
if (global[urlSmb]) defaultApiUrl = global[urlSmb];
}
this.asyncCaller = new AsyncCaller(callerOptions);
this.timeoutMs = config?.timeoutMs;
this.apiUrl = config?.apiUrl?.replace(/\/$/, "") || defaultApiUrl;
this.defaultHeaders = mergeHeaders(config?.defaultHeaders);
this.onRequest = config?.onRequest;
this.streamProtocol = config?.streamProtocol ?? "legacy";
const apiKey = getApiKey(config?.apiKey);
if (apiKey) this.defaultHeaders["x-api-key"] = apiKey;
}
prepareFetchOptions(path, options) {
const mutatedOptions = {
...options,
headers: mergeHeaders(this.defaultHeaders, options?.headers)
};
if (mutatedOptions.json) {
mutatedOptions.body = JSON.stringify(mutatedOptions.json);
mutatedOptions.headers = mergeHeaders(mutatedOptions.headers, { "content-type": "application/json" });
delete mutatedOptions.json;
}
if (mutatedOptions.withResponse) delete mutatedOptions.withResponse;
if ("dedupe" in mutatedOptions) delete mutatedOptions.dedupe;
let timeoutSignal = null;
if (typeof options?.timeoutMs !== "undefined") {
if (options.timeoutMs != null) timeoutSignal = AbortSignal.timeout(options.timeoutMs);
} else if (this.timeoutMs != null) timeoutSignal = AbortSignal.timeout(this.timeoutMs);
mutatedOptions.signal = mergeSignals(timeoutSignal, mutatedOptions.signal);
const targetUrl = new URL(`${this.apiUrl}${path}`);
if (mutatedOptions.params) {
for (const [key, value] of Object.entries(mutatedOptions.params)) {
if (value == null) continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item == null) continue;
targetUrl.searchParams.append(key, typeof item === "string" || typeof item === "number" ? item.toString() : JSON.stringify(item));
}
continue;
}
const strValue = typeof value === "string" || typeof value === "number" ? value.toString() : JSON.stringify(value);
targetUrl.searchParams.append(key, strValue);
}
delete mutatedOptions.params;
}
return [targetUrl, mutatedOptions];
}
async fetch(path, options) {
const [url, init] = this.prepareFetchOptions(path, options);
if (options?.dedupe === true && options?.withResponse !== true && options?.signal == null && this.onRequest == null) {
const body = typeof init.body === "string" ? init.body : "";
/**
* The key must capture the FULL request identity, including every
* prepared header. `inFlightReads` is module-scoped across all
* `Client` instances, so omitting headers would let two clients
* pointed at the same URL/thread but using different credentials
* (Authorization, custom auth headers, tenant-scoping defaults, …)
* share one in-flight promise — a cross-tenant data leak.
*/
const headers = serializeHeaders(init.headers);
const key = `${init.method ?? "GET"} ${url.toString()} ${body} ${headers}`;
const existing = inFlightReads.get(key);
if (existing != null) return existing;
const promise = this.#performFetch(url, init);
inFlightReads.set(key, promise);
const clear = () => {
if (inFlightReads.get(key) === promise) inFlightReads.delete(key);
};
promise.then(clear, clear);
return promise;
}
const [body, response] = await this.#performFetchWithResponse(url, init);
if (options?.withResponse) return [body, response];
return body;
}
/**
* Issue the prepared request (applying the `onRequest` hook) and
* resolve the parsed body. Shared by the deduped and direct paths.
*/
async #performFetch(url, init) {
const [body] = await this.#performFetchWithResponse(url, init);
return body;
}
async #performFetchWithResponse(url, init) {
let finalInit = init;
if (this.onRequest) finalInit = await this.onRequest(url, init);
const response = await this.asyncCaller.fetch(url.toString(), finalInit);
return [await (async () => {
if (response.status === 202 || response.status === 204) return;
return response.json();
})(), response];
}
async *streamWithRetry(config) {
const makeRequest = async (reconnectParams) => {
const requestEndpoint = reconnectParams?.reconnectPath || config.endpoint;
const isReconnect = !!reconnectParams?.reconnectPath;
const method = isReconnect ? "GET" : config.method || "GET";
const requestHeaders = isReconnect && reconnectParams?.lastEventId ? {
...config.headers,
"Last-Event-ID": reconnectParams.lastEventId
} : config.headers;
let [url, init] = this.prepareFetchOptions(requestEndpoint, {
method,
timeoutMs: null,
signal: config.signal,
headers: requestHeaders,
params: config.params,
json: isReconnect ? void 0 : config.json
});
if (this.onRequest != null) init = await this.onRequest(url, init);
const response = await this.asyncCaller.fetch(url.toString(), init);
if (!response.body) throw new Error("Expected response body from stream endpoint");
if (!isReconnect && config.onInitialResponse) await config.onInitialResponse(response);
const idleMode = config.idleReconnect ?? "auto";
const enableIdle = idleMode === "auto" || idleMode > 0;
const lines = response.body.pipeThrough(BytesLineDecoder());
return {
response,
stream: (enableIdle ? lines.pipeThrough(idleReconnectStream({ mode: idleMode })) : lines).pipeThrough(SSEDecoder())
};
};
yield* streamWithRetry(makeRequest, {
maxRetries: config.maxRetries ?? 5,
signal: config.signal,
onReconnect: config.onReconnect
});
}
};
const REGEX_RUN_METADATA = /(\/threads\/(?<thread_id>.+))?\/runs\/(?<run_id>.+)/;
function getRunMetadataFromResponse(response) {
const contentLocation = response.headers.get("Content-Location");
if (!contentLocation) return void 0;
const match = REGEX_RUN_METADATA.exec(contentLocation);
if (!match?.groups?.run_id) return void 0;
return {
run_id: match.groups.run_id,
thread_id: match.groups.thread_id || void 0
};
}
/**
* Module-scoped, in-flight-only coalescing map for idempotent reads.
*
* Two independently-constructed clients (e.g. a React component that
* remounts under Suspense / a reachability state flip, each minting a
* fresh `Client`) can fire the *same* `getState` / `getHistory` read a
* few milliseconds apart, before the first has resolved. Without
* coalescing each pays the full round-trip — the duplicate
* `threads/{id}/state` and `threads/{id}/history` requests seen on
* reconnect.
*
* Keyed by `method + url + body + auth`, entries live only while a
* request is in flight and are removed the moment it settles. This is
* deliberately *not* a result cache: there is no TTL and no stored
* payload, so it cannot serve stale data — it only ever shares a
* promise that is already on the wire. Opt-in per call via
* `{ dedupe: true }`, and skipped whenever the caller supplies its own
* `AbortSignal` (so one consumer aborting can never cancel another's
* read).
*/
const inFlightReads = /* @__PURE__ */ new Map();
/**
* Deterministically serialize a prepared request's headers into a
* stable string for use in the {@link inFlightReads} dedupe key. Header
* names are normalized and sorted so ordering differences never produce
* a different key, and every header (not just `x-api-key`) is included
* so requests carrying different credentials never collide.
*/
function serializeHeaders(headers) {
const normalized = mergeHeaders(headers);
return Object.keys(normalized).sort().map((name) => `${name}:${normalized[name]}`).join("\n");
}
//#endregion
export { BaseClient, encodePathSegment, getApiKey, getRunMetadataFromResponse };
//# sourceMappingURL=base.js.map