UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

276 lines (275 loc) • 10.9 kB
const require_async_caller = require("../utils/async_caller.cjs"); const require_env = require("../utils/env.cjs"); const require_signals = require("../utils/signals.cjs"); const require_sse = require("../utils/sse.cjs"); const require_stream = require("../utils/stream.cjs"); //#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 = require_env.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 require_async_caller.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 = require_signals.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(require_sse.BytesLineDecoder()); return { response, stream: (enableIdle ? lines.pipeThrough(require_stream.idleReconnectStream({ mode: idleMode })) : lines).pipeThrough(require_sse.SSEDecoder()) }; }; yield* require_stream.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 exports.BaseClient = BaseClient; exports.encodePathSegment = encodePathSegment; exports.getApiKey = getApiKey; exports.getRunMetadataFromResponse = getRunMetadataFromResponse; //# sourceMappingURL=base.cjs.map