@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +21 -0
- package/dist/providers/openai-provider.d.ts +28 -0
- package/dist/providers/openai-provider.js +308 -0
- package/package.json +37 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|