@agentforge-io/llm-langchain 0.1.0

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.
@@ -0,0 +1 @@
1
+ export { OpenAIProvider, type OpenAIProviderOptions, } from './providers/openai-provider';
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ // ─── @agentforge-io/llm-langchain ────────────────────────────────────────────
3
+ //
4
+ // LangChain-backed providers that implement the framework-free `LLMProvider`
5
+ // contract from `@agentforge-io/core/ai`. Drop one of these into the agent
6
+ // runner and the agent loop, tool dispatch, approval gating, and model
7
+ // routing keep working unchanged.
8
+ //
9
+ // Currently shipped:
10
+ // - OpenAIProvider — covers OpenAI and any OpenAI-compatible endpoint
11
+ // (Grok via x.ai, Together, vLLM, local Ollama) by setting `baseURL`.
12
+ //
13
+ // Planned (next):
14
+ // - GeminiProvider via `@langchain/google-genai`
15
+ //
16
+ // The host wires which provider runs per tenant; this package only ships
17
+ // the adapters, not the resolution logic.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.OpenAIProvider = void 0;
20
+ var openai_provider_1 = require("./providers/openai-provider");
21
+ Object.defineProperty(exports, "OpenAIProvider", { enumerable: true, get: function () { return openai_provider_1.OpenAIProvider; } });
@@ -0,0 +1,28 @@
1
+ import type { LLMProvider, LLMProviderCapabilities, LLMStreamEvent, LLMStreamParams } from '@agentforge-io/core/ai';
2
+ export interface OpenAIProviderOptions {
3
+ apiKey: string;
4
+ /** Override the API base URL. Used for OpenAI-compatible endpoints
5
+ * (Grok via x.ai, local Ollama, vLLM, Together, etc.) without
6
+ * shipping a separate provider class. When unset LangChain uses
7
+ * OpenAI's production endpoint. */
8
+ baseURL?: string;
9
+ /** Organization id (OpenAI-specific). Ignored by compatible endpoints. */
10
+ organization?: string;
11
+ /** Provider id surfaced to the platform's resolver. Defaults to
12
+ * `'openai'`. Subclasses that target a compatible endpoint
13
+ * (Grok, etc.) override this so the runner can attribute usage
14
+ * to the right provider in telemetry. */
15
+ id?: string;
16
+ /** Human-readable label. Defaults to `'OpenAI'`. */
17
+ displayName?: string;
18
+ }
19
+ export declare class OpenAIProvider implements LLMProvider {
20
+ readonly id: string;
21
+ readonly displayName: string;
22
+ readonly capabilities: LLMProviderCapabilities;
23
+ private readonly apiKey;
24
+ private readonly baseURL?;
25
+ private readonly organization?;
26
+ constructor(opts: OpenAIProviderOptions);
27
+ stream(params: LLMStreamParams): AsyncGenerator<LLMStreamEvent>;
28
+ }
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ // ─── OpenAI provider (via LangChain) ────────────────────────────────────────
3
+ //
4
+ // Wraps `@langchain/openai`'s ChatOpenAI behind the framework-free
5
+ // `LLMProvider` contract from `@agentforge-io/core`. The runner doesn't
6
+ // know it's talking to OpenAI — it gets the same stream-event shape as
7
+ // AnthropicProvider, the same tool schema in, and the same `message_stop`
8
+ // envelope out.
9
+ //
10
+ // We deliberately use LangChain (not `openai` directly) because:
11
+ // 1. It already normalises OpenAI's two streaming shapes (Chat Completions
12
+ // legacy + the newer Responses API) into a single message stream.
13
+ // 2. Drop-in support for OpenAI-compatible endpoints (Grok, local Ollama,
14
+ // vLLM, Together, etc.) via `configuration.baseURL` — adding Grok costs
15
+ // us ~5 lines, not a separate transport.
16
+ // 3. The tool-calling adapter (`convertToOpenAITool`) is published and
17
+ // maintained by LangChain — we don't reinvent the JSON-schema → OpenAI
18
+ // function-spec translation.
19
+ //
20
+ // Tool flow mismatch with Anthropic worth calling out: OpenAI emits the tool
21
+ // arguments INCREMENTALLY (one piece of the JSON object per stream event),
22
+ // then closes the message with `finish_reason: 'tool_calls'`. Anthropic emits
23
+ // the parsed input atomically once the `tool_use` content block is complete.
24
+ // We bridge by buffering the streamed arguments and parsing once at the end —
25
+ // the runner still gets the `tool_use_start` event with full input, matching
26
+ // the Anthropic contract.
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.OpenAIProvider = void 0;
29
+ const openai_1 = require("@langchain/openai");
30
+ const messages_1 = require("@langchain/core/messages");
31
+ class OpenAIProvider {
32
+ constructor(opts) {
33
+ this.capabilities = {
34
+ supportsTools: true,
35
+ supportsStreaming: true,
36
+ supportsTemperature: true,
37
+ // OpenAI's tool-calling lets the model emit multiple `tool_calls`
38
+ // in one assistant turn, same as Anthropic.
39
+ supportsParallelTools: true,
40
+ };
41
+ this.id = opts.id ?? 'openai';
42
+ this.displayName = opts.displayName ?? 'OpenAI';
43
+ this.apiKey = opts.apiKey;
44
+ this.baseURL = opts.baseURL;
45
+ this.organization = opts.organization;
46
+ }
47
+ async *stream(params) {
48
+ // ChatOpenAI is constructed PER CALL because the model id varies
49
+ // (the runner picks a model per turn via `selectModel()`). The HTTP
50
+ // client itself is stateless so there's no perf hit; if profiling
51
+ // ever shows one, we can cache by (model, temperature) tuple.
52
+ const llm = new openai_1.ChatOpenAI({
53
+ apiKey: this.apiKey,
54
+ model: params.model,
55
+ temperature: params.temperature,
56
+ maxTokens: params.maxTokens,
57
+ streaming: true,
58
+ configuration: {
59
+ baseURL: this.baseURL,
60
+ organization: this.organization,
61
+ },
62
+ });
63
+ // Bind tools onto a fresh model handle. LangChain's `bindTools`
64
+ // translates our Anthropic-shaped `LLMToolSchema[]` into OpenAI's
65
+ // `{ type: 'function', function: {...} }` envelope. We don't have
66
+ // to hand-write the converter — LC owns that mapping and keeps it
67
+ // in sync with the OpenAI SDK.
68
+ const bound = params.tools && params.tools.length > 0
69
+ ? llm.bindTools(params.tools.map(toLangChainTool))
70
+ : llm;
71
+ const messages = toLangChainMessages(params.systemPrompt, params.messages);
72
+ // Buffers built up across the stream — flushed when we emit the
73
+ // single `message_stop` event at the end.
74
+ let textBuffer = '';
75
+ // Tool calls arrive incrementally. Keyed by index because OpenAI
76
+ // can stream multiple tool calls in parallel and we have to
77
+ // accumulate each one's `arguments` JSON chunk-by-chunk.
78
+ const toolCalls = new Map();
79
+ let usageInput = 0;
80
+ let usageOutput = 0;
81
+ let stopReason = 'end_turn';
82
+ const stream = await bound.stream(messages);
83
+ for await (const chunk of stream) {
84
+ // Text token. LangChain normalises the OpenAI delta into
85
+ // `chunk.content` (string when single-modal, array when
86
+ // multi-modal — we coerce to string).
87
+ const text = typeof chunk.content === 'string'
88
+ ? chunk.content
89
+ : chunk.content
90
+ .map((c) => (typeof c === 'string' ? c : 'text' in c ? c.text : ''))
91
+ .join('');
92
+ if (text) {
93
+ textBuffer += text;
94
+ yield { type: 'text_delta', delta: text };
95
+ }
96
+ // Tool-call deltas. The first chunk per index carries the call's
97
+ // `id` and `name`; subsequent chunks just stream more of the
98
+ // JSON `args` string. We emit `tool_use_start` ONCE per tool
99
+ // call — when we've seen the name for the first time AND when
100
+ // arguments have been fully accumulated (we re-parse at the end
101
+ // because OpenAI sometimes only sends the final args in the
102
+ // CLOSING chunk, not per delta).
103
+ const deltaToolCalls = chunk.tool_call_chunks ?? [];
104
+ for (const tc of deltaToolCalls) {
105
+ const idx = tc.index ?? 0;
106
+ const existing = toolCalls.get(idx);
107
+ if (!existing) {
108
+ toolCalls.set(idx, {
109
+ id: tc.id ?? `call_${idx}`,
110
+ name: tc.name ?? '',
111
+ argsBuffer: tc.args ?? '',
112
+ started: false,
113
+ });
114
+ }
115
+ else {
116
+ if (tc.id && !existing.id.startsWith('call_'))
117
+ existing.id = tc.id;
118
+ if (tc.name)
119
+ existing.name = tc.name;
120
+ if (tc.args)
121
+ existing.argsBuffer += tc.args;
122
+ }
123
+ }
124
+ // Usage usually only lands in the final chunk (OpenAI sends it
125
+ // alongside `finish_reason`); accumulate defensively in case
126
+ // intermediate chunks ever carry partial counts.
127
+ const usageMeta = chunk
128
+ .usage_metadata;
129
+ if (usageMeta) {
130
+ usageInput = usageMeta.input_tokens ?? usageInput;
131
+ usageOutput = usageMeta.output_tokens ?? usageOutput;
132
+ }
133
+ // Finish reason — LangChain stamps this on the LAST chunk via
134
+ // `response_metadata.finish_reason` or similar; we read both
135
+ // common shapes.
136
+ const finishReason = chunk
137
+ .response_metadata?.finish_reason ??
138
+ chunk.finish_reason;
139
+ if (finishReason) {
140
+ stopReason = normalizeFinishReason(finishReason);
141
+ }
142
+ }
143
+ // Once the stream's exhausted, emit a tool_use_start per fully
144
+ // accumulated tool call. We parse the buffered JSON args now —
145
+ // doing it here (instead of incrementally) matches the Anthropic
146
+ // contract where the runner gets the complete input in one event.
147
+ const finalContent = [];
148
+ if (textBuffer) {
149
+ finalContent.push({ type: 'text', text: textBuffer });
150
+ }
151
+ for (const [, call] of toolCalls) {
152
+ if (!call.name)
153
+ continue;
154
+ let parsedInput = {};
155
+ try {
156
+ parsedInput = call.argsBuffer
157
+ ? JSON.parse(call.argsBuffer)
158
+ : {};
159
+ }
160
+ catch {
161
+ // If OpenAI streamed malformed JSON (it shouldn't, but
162
+ // gateways and proxies can corrupt it), surface empty input
163
+ // rather than crashing the runner — the tool dispatch will
164
+ // fail with a more actionable error than a JSON parse error.
165
+ parsedInput = {};
166
+ }
167
+ yield {
168
+ type: 'tool_use_start',
169
+ toolUseId: call.id,
170
+ toolName: call.name,
171
+ input: parsedInput,
172
+ };
173
+ finalContent.push({
174
+ type: 'tool_use',
175
+ id: call.id,
176
+ name: call.name,
177
+ input: parsedInput,
178
+ });
179
+ }
180
+ // If the model emitted tool calls AND text, OpenAI's finish reason
181
+ // is `tool_calls` (not `stop`). Mirror Anthropic's `tool_use` so
182
+ // the runner re-enters the loop with tool results.
183
+ if (toolCalls.size > 0 && stopReason !== 'max_tokens') {
184
+ stopReason = 'tool_use';
185
+ }
186
+ yield {
187
+ type: 'usage_delta',
188
+ usage: {
189
+ inputTokens: usageInput,
190
+ outputTokens: usageOutput,
191
+ totalTokens: usageInput + usageOutput,
192
+ },
193
+ };
194
+ yield {
195
+ type: 'message_stop',
196
+ stopReason,
197
+ content: finalContent,
198
+ };
199
+ }
200
+ }
201
+ exports.OpenAIProvider = OpenAIProvider;
202
+ // ─── Translation helpers ────────────────────────────────────────────────────
203
+ /**
204
+ * Translate our provider-agnostic tool schema into the shape LangChain's
205
+ * `bindTools` expects. The actual conversion to OpenAI's
206
+ * `{ type: 'function', function: {...} }` envelope happens inside
207
+ * LangChain via `convertToOpenAITool`.
208
+ */
209
+ function toLangChainTool(t) {
210
+ return {
211
+ type: 'function',
212
+ function: {
213
+ name: t.name,
214
+ description: t.description,
215
+ // Our `input_schema` is already JSON Schema (Anthropic uses the same
216
+ // dialect OpenAI does — just lives at a different key). Passing it
217
+ // through directly avoids a translation pass and works because
218
+ // LangChain inspects `parameters` for both providers.
219
+ parameters: t.input_schema,
220
+ },
221
+ };
222
+ }
223
+ /**
224
+ * Translate our chat history into LangChain's `BaseMessage[]`. Tool
225
+ * results become `ToolMessage`s (with `tool_call_id`), tool calls live
226
+ * inside `AIMessage.tool_calls`, and plain text content lives where
227
+ * you'd expect.
228
+ *
229
+ * We compose the system prompt as the first message rather than
230
+ * configuring it on the ChatOpenAI instance because LC handles
231
+ * pre-pending it identically and this keeps the call site uniform.
232
+ */
233
+ function toLangChainMessages(systemPrompt, messages) {
234
+ const out = [];
235
+ if (systemPrompt) {
236
+ out.push(new messages_1.SystemMessage(systemPrompt));
237
+ }
238
+ for (const m of messages) {
239
+ if (typeof m.content === 'string') {
240
+ out.push(m.role === 'user' ? new messages_1.HumanMessage(m.content) : new messages_1.AIMessage(m.content));
241
+ continue;
242
+ }
243
+ if (m.role === 'user') {
244
+ // User messages with multi-part content come from the runner's
245
+ // tool-result flow: after a tool dispatches, the runner appends
246
+ // a `tool_result` content block under role='user' (mirroring
247
+ // Anthropic's convention). LangChain wants those as a separate
248
+ // ToolMessage per result; split them out here.
249
+ const textParts = [];
250
+ for (const block of m.content) {
251
+ if (block.type === 'text') {
252
+ textParts.push(block.text);
253
+ }
254
+ else if (block.type === 'tool_result') {
255
+ // Flush any pending text before the tool message so the
256
+ // chronology stays right.
257
+ if (textParts.length > 0) {
258
+ out.push(new messages_1.HumanMessage(textParts.join('\n')));
259
+ textParts.length = 0;
260
+ }
261
+ out.push(new messages_1.ToolMessage({
262
+ tool_call_id: block.tool_use_id,
263
+ content: block.content,
264
+ status: block.is_error ? 'error' : 'success',
265
+ }));
266
+ }
267
+ }
268
+ if (textParts.length > 0) {
269
+ out.push(new messages_1.HumanMessage(textParts.join('\n')));
270
+ }
271
+ }
272
+ else {
273
+ // Assistant turns can carry a text part AND tool_use blocks.
274
+ // LangChain represents that as a single AIMessage with `content`
275
+ // (the text) plus a `tool_calls` array.
276
+ const textParts = [];
277
+ const toolCalls = [];
278
+ for (const block of m.content) {
279
+ if (block.type === 'text')
280
+ textParts.push(block.text);
281
+ else if (block.type === 'tool_use')
282
+ toolCalls.push({
283
+ id: block.id,
284
+ name: block.name,
285
+ args: block.input,
286
+ type: 'tool_call',
287
+ });
288
+ }
289
+ out.push(new messages_1.AIMessage({
290
+ content: textParts.join('\n'),
291
+ tool_calls: toolCalls,
292
+ }));
293
+ }
294
+ }
295
+ return out;
296
+ }
297
+ function normalizeFinishReason(raw) {
298
+ switch (raw) {
299
+ case 'tool_calls':
300
+ case 'function_call':
301
+ return 'tool_use';
302
+ case 'length':
303
+ return 'max_tokens';
304
+ case 'stop':
305
+ default:
306
+ return 'end_turn';
307
+ }
308
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@agentforge-io/llm-langchain",
3
+ "version": "0.1.0",
4
+ "description": "LangChain-backed LLM providers (OpenAI, Grok, Gemini) implementing the framework-free `LLMProvider` contract from @agentforge-io/core. Drop-in replacements for AnthropicProvider — same stream events, same tool schema, no changes to the agent runner.",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.build.json",
19
+ "build:watch": "tsc -p tsconfig.build.json --watch",
20
+ "clean": "rm -rf dist *.tgz",
21
+ "test": "node --test --import tsx --test-reporter=spec tests/*.test.ts"
22
+ },
23
+ "peerDependencies": {
24
+ "@agentforge-io/core": ">=2.3.0-rc.0"
25
+ },
26
+ "dependencies": {
27
+ "@langchain/core": "^0.3.0",
28
+ "@langchain/openai": "^0.3.0",
29
+ "@langchain/google-genai": "^0.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@agentforge-io/core": "^2.3.0-rc.0",
33
+ "@types/node": "^20.0.0",
34
+ "tsx": "^4.19.0",
35
+ "typescript": "^5.0.0"
36
+ }
37
+ }