SOLWYN

TypeScript SDK LangChain

Attribute LangChain.js chains and LangGraph.js nodes to stable Solwyn run scopes with the TypeScript SDK's content-free run-scope handler.

@solwyn/sdk/langchain supplies SolwynRunScopeHandler, a content-free LangChain callback handler that attributes LangChain.js chains and LangGraph.js nodes to stable Solwyn run scopes. It reads structural run IDs, parent IDs, explicit names, cancellation signals, and metadata.langgraph_node; it never reads model input, output, tool arguments, or usage.

The handler does not check budgets or settle usage. Enforcement and settlement happen only when a provider call crosses the Solwyn-wrapped OpenAI client in the recipe below. Wrap the OpenAI client, not a ChatOpenAI instance.

Requirements

  • Node.js 22 or later, with @solwyn/sdk/node imported. Constructing the handler without the Node entry throws ConfigurationError; edge applications can still use a wrapped client directly for enforcement without framework attribution.
  • @solwyn/sdk, openai, @langchain/core, @langchain/openai, and @langchain/langgraph. The optional peer range is @langchain/core >=1.2.14 <2.
  • The recipe is checked with @langchain/core@1.2.18, @langchain/openai@1.6.2, @langchain/langgraph@1.5.1, and openai@7.36.0, and its declarations with TypeScript 5.8.3 and 6.0.3 under skipLibCheck: false.

The new optional peer can cause npm ERESOLVE when an application already installs an out-of-range @langchain/core, even if it does not use this integration. Upgrade the installed core to the supported range.

Chat Completions recipe

Keep the handler, the completions subclass, the refusing Responses implementation, and the refusing fetch together. The Responses refusal runs before LangChain's retry caller, and configuration.fetch is a backstop for any separately constructed client; useResponsesApi: false alone cannot prevent automatic Responses selection for every model or feature. Leave onFailedAttempt unset so LangChain keeps its default classification of non-retryable provider errors.

import OpenAI from "openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI, ChatOpenAICompletions, ChatOpenAIResponses } from "@langchain/openai";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { Solwyn, SolwynError } from "@solwyn/sdk/node";
import { SolwynRunScopeHandler } from "@solwyn/sdk/langchain";

const handler = new SolwynRunScopeHandler();
const solwyn = new Solwyn(new OpenAI({ maxRetries: 0 }), {
  apiKey: process.env.SOLWYN_API_KEY!,
});
const leaf = handler.chatCompletionsLeaf(solwyn);

class SolwynChatCompletions extends ChatOpenAICompletions {
  constructor(fields: ConstructorParameters<typeof ChatOpenAICompletions>[0]) {
    super(fields);
    this.client = leaf as typeof this.client;
  }
  override _generate(
    ...[opaque, options, manager]: Parameters<ChatOpenAICompletions["_generate"]>
  ) {
    return super._generate(opaque, handler.bindModelCall(options, manager), manager);
  }
  override _streamResponseChunks(
    ...[opaque, options, manager]: Parameters<ChatOpenAICompletions["_streamResponseChunks"]>
  ) {
    return super._streamResponseChunks(opaque, handler.bindModelCall(options, manager), manager);
  }
  override async *_streamChatModelEvents(): AsyncGenerator<never> {
    throw new SolwynError("LangChain chat-model stream events are not admitted");
  }
}
class RefuseResponses extends ChatOpenAIResponses {
  override async _generate(): Promise<never> {
    throw new SolwynError("LangChain Responses API is not admitted");
  }
  override async *_streamResponseChunks(): AsyncGenerator<never> {
    throw new SolwynError("LangChain Responses API is not admitted");
  }
  override async *_streamChatModelEvents(): AsyncGenerator<never> {
    throw new SolwynError("LangChain Responses API is not admitted");
  }
}
const fields = {
  model: "gpt-4.1-mini",
  apiKey: "unused-by-metered-path",
  useResponsesApi: false,
  maxRetries: 0,
  configuration: {
    fetch: () => Promise.reject(new SolwynError("Unmetered LangChain client refused")),
  },
};
const model = new ChatOpenAI({
  ...fields,
  completions: new SolwynChatCompletions(fields),
  responses: new RefuseResponses(fields),
});
const prompt = ChatPromptTemplate.fromTemplate("Answer this synthetic request: {question}");
const chain = prompt.pipe(model).withConfig({ runName: "support-chain", callbacks: [handler] });
const State = Annotation.Root({ done: Annotation<boolean>() });
const graph = new StateGraph(State)
  .addNode("answer", async () => {
    await model.invoke("Synthetic graph request");
    return { done: true };
  })
  .addEdge(START, "answer")
  .addEdge("answer", END)
  .compile()
  .withConfig({ runName: "support-graph", callbacks: [handler] });
try {
  await chain.invoke({ question: "fixture" });
  for await (const _ of await chain.stream({ question: "fixture" })) { /* consume */ }
  await graph.invoke({});
} finally {
  await solwyn.close();
}

handler.chatCompletionsLeaf(solwyn) returns a client that admits only chat.completions.create and rejects a raw OpenAI client. handler.bindModelCall(options, manager) binds each model call to its scope. LangChain must propagate the handler to every model run; calling the model directly without a parent chain fails closed. Do not replace options.options.signal, because it overrides the per-call binding on LangChain's non-streaming path and the leaf then refuses dispatch.

This is a version-sensitive recipe: it depends on the completions client field, signal forwarding, and callback order. Upgrade these packages together and rerun your recipe tests.

Admitted and tested paths

  • Non-structured Chat Completions through chain invoke and stream.
  • Compiled LangGraph invoke and stream with streamMode: "values", "updates", or "messages", including parallel nodes, model maxConcurrency: 1, concurrent calls within a node, and repeated node iterations.
  • bindTools with function tools that make no model calls, and withConfig.
  • Budget denial, the default retry veto on Solwyn errors, native provider retry classification, and cooperative exact-run stop at the next provider-chunk boundary.

Refused and unsupported paths

  • The Responses API, whether selected explicitly or automatically, is refused before dispatch.
  • Non-streaming JSON-schema structured output uses chat.completions.parse, which the leaf refuses. Streaming structured output is outside the tested recipe.
  • _streamChatModelEvents, LangGraph v3 messages streaming, and streamEvents are not admitted.
  • generate/batch, __includeRawResponse, Azure, @langchain/langgraph/web, ChatAnthropic, and other provider integrations are not admitted.
  • Parallel graph fan-out with graph-level maxConcurrency: 1 is excluded.
  • Model calls inside tools or retrievers are outside the tested recipe.

Refusals are SolwynErrors marked non-retryable for LangChain. A model that succeeds without binding this handler's leaf raises SolwynError after the call, because the call was not metered; that diagnostic cannot undo provider work. Failed model callbacks keep the original error.

Early stream termination

Ending a stream early with break or iterator return(), and abort-and-drain on Node.js 22, can omit LangChain's terminal root callbacks. A reused handler then retains scope state, and on Node.js 22 the run's token lease stays reserved until client.close(). Consume streams fully when you reuse a handler. If a stream may be cancelled or ended early, create a handler local to that invocation and release it with the stream.

Scopes, tags, and tracing

Ordinary chains attribute model calls to a lazily created root scope. LangGraph nodes reuse one scope per root, enclosing node scope, and node name across iterations, which preserves lease reuse and velocity history. Nested node scopes are children; tools and retrievers add no Solwyn runs. Under an outer run(...), the root is its child.

Calls carry the leaf scope's exact run ID, so Cloud stops and per-run controls must target that leaf ID. Stopping a parent does not stop its children.

Scope names come from runName, graph-node names, and the namePrefix option (default "langchain"). They must contain only non-sensitive attribution. Control, format, separator, and lone-surrogate characters are replaced, and names are clamped to the 255-unit run-name limit. An invalid namePrefix throws ConfigurationError at construction.

new SolwynRunScopeHandler({ tags: { workflow: "support" } }) applies explicit tags at the root, and children inherit them. Tags are sent verbatim, must be non-sensitive, and make calls ineligible for token leases. LangChain's own callback tags are not copied to Solwyn.

LangSmith tracing exports prompt and response content to LangSmith, outside Solwyn's privacy boundary. Keep LANGSMITH_TRACING and LANGCHAIN_TRACING_V2 off unless that export is intended. The SDK never enables LangSmith tracing.

On this page