@astack-tech/integrations 0.1.1-beta.6 → 0.2.0-beta.10

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,531 @@
1
+ import { Component } from '@astack-tech/core';
2
+ import type { Port } from '@astack-tech/core';
3
+ import OpenAI from 'openai';
4
+
5
+ /**
6
+ * Options for a provider that speaks the OpenAI chat completions API.
7
+ */
8
+ export interface OpenAICompatibleConfig {
9
+ /** API key of the provider. */
10
+ apiKey: string;
11
+ /** Model name, for example `gpt-4o-mini`, `qwen-plus` or `llama3.1`. */
12
+ model: string;
13
+ /** API base URL. Defaults to `https://api.openai.com/v1`. */
14
+ baseURL?: string | undefined;
15
+ /** Provider name used in errors and logs. Defaults to `The model API`. */
16
+ name?: string | undefined;
17
+ /** Sampling temperature. Defaults to `0.7`. */
18
+ temperature?: number | undefined;
19
+ /** Maximum number of generated tokens. */
20
+ maxTokens?: number | undefined;
21
+ /** Nucleus sampling probability. */
22
+ topP?: number | undefined;
23
+ /** System prompt added before every request. */
24
+ systemPrompt?: string | undefined;
25
+ /**
26
+ * Tools given as `{ name, description, parameters }` objects.
27
+ * When this list is not empty, it takes precedence over `tools`.
28
+ */
29
+ rawTools?: RawTool[] | undefined;
30
+ /** Tools in the OpenAI-compatible `{ type: 'function', function }` format. */
31
+ tools?: unknown[] | undefined;
32
+ /** Allows the OpenAI client to run in a browser. Defaults to `false`. */
33
+ dangerouslyAllowBrowser?: boolean | undefined;
34
+ /** Logs API errors to the console. Defaults to `false`. */
35
+ verbose?: boolean | undefined;
36
+ }
37
+
38
+ /**
39
+ * Tool description accepted by `rawTools` and `temporaryTools`.
40
+ */
41
+ export interface RawTool {
42
+ /** Unique tool name. */
43
+ name: string;
44
+ /** Description that tells the model when to use the tool. */
45
+ description: string;
46
+ /** JSON Schema of the arguments. Defaults to `{}`. */
47
+ parameters?: Record<string, unknown> | undefined;
48
+ }
49
+
50
+ /**
51
+ * Role of a chat message.
52
+ */
53
+ export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
54
+
55
+ /**
56
+ * Tool call in the internal format.
57
+ */
58
+ export interface ToolCall {
59
+ /** Identifier assigned by the API. */
60
+ id?: string | undefined;
61
+ /** Name of the tool. */
62
+ tool_name: string;
63
+ /** Parsed arguments. */
64
+ arguments: Record<string, unknown>;
65
+ }
66
+
67
+ /**
68
+ * Tool call returned by `chatCompletion`. It carries both the OpenAI shape
69
+ * (`type`, `function`) and the internal shape (`tool_name`, `arguments`).
70
+ */
71
+ export interface ResponseToolCall {
72
+ /** Identifier assigned by the API. A `tool` message answers the call with it. */
73
+ id: string;
74
+ /** Always `function`. */
75
+ type: 'function';
76
+ /** The call in the OpenAI shape: function name and raw JSON arguments. An empty argument string is sent as `{}`. */
77
+ function: {
78
+ /** Name of the tool to call. */
79
+ name: string;
80
+ /** Arguments as JSON text. */
81
+ arguments: string;
82
+ };
83
+ /** Name of the tool, same as `function.name`. */
84
+ tool_name: string;
85
+ /** Parsed arguments. JSON that is not an object becomes `{}`. */
86
+ arguments: Record<string, unknown>;
87
+ }
88
+
89
+ /**
90
+ * Partial tool call emitted while streaming. `arguments` holds the raw JSON fragment.
91
+ */
92
+ export interface StreamToolCall {
93
+ /** Identifier assigned by the API. Only the first delta of a call carries it; later deltas have `''`. */
94
+ id: string;
95
+ /** Always `function`. */
96
+ type: 'function';
97
+ /** The delta in the OpenAI shape. `arguments` holds only this delta's JSON fragment. */
98
+ function: {
99
+ /** Name of the tool to call. */
100
+ name: string;
101
+ /** Arguments as JSON text. */
102
+ arguments: string;
103
+ };
104
+ /** Name of the tool when this delta carries it, otherwise `''`. */
105
+ tool_name: string;
106
+ /** Raw JSON fragment of this delta. Concatenate the fragments of a call to get its arguments. */
107
+ arguments: string;
108
+ }
109
+
110
+ /**
111
+ * Token usage reported by the API.
112
+ */
113
+ export interface TokenUsage {
114
+ /** Tokens in the generated reply. */
115
+ completion_tokens: number;
116
+ /** Tokens in the request. */
117
+ prompt_tokens: number;
118
+ /** Prompt tokens served from the provider's context cache (reported by DeepSeek). */
119
+ prompt_cache_hit_tokens?: number | undefined;
120
+ /** Prompt tokens not served from the cache. */
121
+ prompt_cache_miss_tokens?: number | undefined;
122
+ /** Sum of prompt and completion tokens. */
123
+ total_tokens: number;
124
+ }
125
+
126
+ /**
127
+ * Chat message accepted as input. It matches the agent message contract in
128
+ * `@astack-tech/components`, so agent histories can be passed directly.
129
+ */
130
+ export interface Message {
131
+ /** One of `system`, `user`, `assistant` or `tool` (see `MessageRole`). */
132
+ role: string;
133
+ /** Text content. Omitted content is sent as no content. */
134
+ content?: string | undefined;
135
+ /** Accepted for compatibility. Assistant tool calls are not forwarded to the API. */
136
+ tool_calls?: readonly unknown[] | undefined;
137
+ /** Identifier of the call that a `tool` message answers. */
138
+ tool_call_id?: string | undefined;
139
+ /** Ignored on input. Present so agent histories can be passed unchanged. */
140
+ usage?: TokenUsage | undefined;
141
+ }
142
+
143
+ /**
144
+ * Assistant message returned by `chatCompletion`.
145
+ */
146
+ export interface AssistantMessage {
147
+ /** Always `assistant`. */
148
+ role: 'assistant';
149
+ /** Reply text. Empty when the model only calls tools. */
150
+ content: string;
151
+ /** Tool calls requested by the model, if any. */
152
+ tool_calls?: ResponseToolCall[] | undefined;
153
+ /** Token usage of this request, when the API reports it. */
154
+ usage?: TokenUsage | undefined;
155
+ }
156
+
157
+ /**
158
+ * Chunk yielded by `streamChatCompletion`.
159
+ */
160
+ export interface StreamChunk {
161
+ /** Always `assistant`. */
162
+ role: 'assistant';
163
+ /** Text delta. Empty in chunks that carry tool calls or usage. */
164
+ content: string;
165
+ /** Tool-call deltas in this chunk. */
166
+ tool_calls?: StreamToolCall[] | undefined;
167
+ /** Token usage. Sent once, in the final chunk, when the API reports it. */
168
+ usage?: TokenUsage | undefined;
169
+ }
170
+
171
+ /**
172
+ * Per-call options.
173
+ */
174
+ export interface ChatCompletionOptions {
175
+ /** Tools for this call only. They replace the configured tools. */
176
+ temporaryTools?: unknown[] | undefined;
177
+ }
178
+
179
+ type ChatMessageParam = OpenAI.Chat.ChatCompletionMessageParam;
180
+ type ChatTool = OpenAI.Chat.ChatCompletionTool;
181
+
182
+ interface ApiUsage {
183
+ completion_tokens?: number;
184
+ prompt_tokens?: number;
185
+ total_tokens?: number;
186
+ prompt_cache_hit_tokens?: number;
187
+ prompt_cache_miss_tokens?: number;
188
+ }
189
+
190
+ const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
191
+ const DEFAULT_NAME = 'The model API';
192
+ const DEFAULT_TEMPERATURE = 0.7;
193
+ const FALLBACK_TEXT = 'API call failed';
194
+
195
+ function isRecord(value: unknown): value is Record<string, unknown> {
196
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
197
+ }
198
+
199
+ /**
200
+ * Converts a `{ name, description, parameters }` object to the OpenAI tool format.
201
+ * Objects already in the OpenAI format are returned unchanged.
202
+ */
203
+ function toApiTool(tool: unknown): ChatTool {
204
+ const raw = isRecord(tool) ? tool : {};
205
+ const definition = raw['function'];
206
+ if (raw['type'] === 'function' && isRecord(definition)) {
207
+ const { name, description, parameters, strict } = definition;
208
+ return {
209
+ type: 'function',
210
+ function: {
211
+ name: typeof name === 'string' ? name : '',
212
+ ...(typeof description === 'string' ? { description } : {}),
213
+ ...(isRecord(parameters) ? { parameters } : {}),
214
+ ...(typeof strict === 'boolean' ? { strict } : {}),
215
+ },
216
+ };
217
+ }
218
+ const { name, description, parameters } = raw;
219
+ return {
220
+ type: 'function',
221
+ function: {
222
+ name: typeof name === 'string' ? name : '',
223
+ description: typeof description === 'string' ? description : '',
224
+ parameters: isRecord(parameters) ? parameters : {},
225
+ },
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Parses tool-call arguments. Anything that is not a JSON object becomes `{}`.
231
+ */
232
+ function parseArguments(raw: string): Record<string, unknown> {
233
+ if (raw.trim().length === 0) {
234
+ return {};
235
+ }
236
+ try {
237
+ const parsed: unknown = JSON.parse(raw);
238
+ return isRecord(parsed) ? parsed : {};
239
+ } catch {
240
+ return {};
241
+ }
242
+ }
243
+
244
+ function mapUsage(usage: ApiUsage): TokenUsage {
245
+ return {
246
+ completion_tokens: usage.completion_tokens ?? 0,
247
+ prompt_tokens: usage.prompt_tokens ?? 0,
248
+ total_tokens: usage.total_tokens ?? 0,
249
+ ...(usage.prompt_cache_hit_tokens === undefined
250
+ ? {}
251
+ : { prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens }),
252
+ ...(usage.prompt_cache_miss_tokens === undefined
253
+ ? {}
254
+ : { prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens }),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Model provider for any API that speaks the OpenAI chat completions protocol:
260
+ * OpenAI itself, and the compatible endpoints of DeepSeek, Qwen, Moonshot, Ollama,
261
+ * vLLM and others. Set `baseURL` and `model` for the provider you use.
262
+ *
263
+ * Ports:
264
+ * - `prompt` (in): a text prompt. The answer is sent on `completion`.
265
+ * - `messages` (in): a list of chat messages. The reply is sent on `message`.
266
+ */
267
+ class OpenAICompatible extends Component {
268
+ private readonly client: OpenAI;
269
+ private readonly model: string;
270
+ private readonly temperature: number;
271
+ private readonly maxTokens: number | undefined;
272
+ private readonly topP: number | undefined;
273
+ private readonly systemPrompt: string | undefined;
274
+ private readonly tools: unknown[] | undefined;
275
+ private readonly verbose: boolean;
276
+ private readonly name: string;
277
+
278
+ constructor(config: OpenAICompatibleConfig) {
279
+ super({});
280
+
281
+ this.client = new OpenAI({
282
+ apiKey: config.apiKey,
283
+ baseURL: config.baseURL ?? DEFAULT_BASE_URL,
284
+ dangerouslyAllowBrowser: config.dangerouslyAllowBrowser ?? false,
285
+ });
286
+
287
+ this.model = config.model;
288
+ this.name = config.name ?? DEFAULT_NAME;
289
+ this.temperature = config.temperature ?? DEFAULT_TEMPERATURE;
290
+ this.maxTokens = config.maxTokens;
291
+ this.topP = config.topP;
292
+ this.systemPrompt = config.systemPrompt;
293
+ this.verbose = config.verbose ?? false;
294
+ this.tools =
295
+ config.rawTools !== undefined && config.rawTools.length > 0
296
+ ? config.rawTools.map(toApiTool)
297
+ : config.tools;
298
+
299
+ Component.Port.I('prompt').attach(this);
300
+ Component.Port.I('messages').attach(this);
301
+ Component.Port.O('completion').attach(this);
302
+ Component.Port.O('message').attach(this);
303
+ }
304
+
305
+ /**
306
+ * Generates a completion for a single prompt.
307
+ *
308
+ * @returns The text answer, or the JSON-encoded assistant message when it contains tool calls.
309
+ */
310
+ async generateCompletion(prompt: string): Promise<string> {
311
+ const response = await this.client.chat.completions.create({
312
+ ...this.baseRequest(),
313
+ messages: [...this.systemMessages(), { role: 'user', content: prompt }],
314
+ ...this.toolsParam(this.tools),
315
+ });
316
+
317
+ const message = this.firstMessage(response);
318
+ if (message.tool_calls !== undefined && message.tool_calls.length > 0) {
319
+ return JSON.stringify(message);
320
+ }
321
+ return message.content ?? '';
322
+ }
323
+
324
+ /**
325
+ * Sends a conversation and returns the assistant reply.
326
+ */
327
+ async chatCompletion(
328
+ messages: Message[],
329
+ options?: ChatCompletionOptions
330
+ ): Promise<AssistantMessage> {
331
+ const response = await this.client.chat.completions.create({
332
+ ...this.baseRequest(),
333
+ messages: this.formatMessages(messages),
334
+ ...this.toolsParam(options?.temporaryTools ?? this.tools),
335
+ });
336
+
337
+ const message = this.firstMessage(response);
338
+ const result: AssistantMessage = { role: 'assistant', content: message.content ?? '' };
339
+
340
+ if (response.usage !== undefined) {
341
+ result.usage = mapUsage(response.usage);
342
+ }
343
+
344
+ if (message.tool_calls !== undefined && message.tool_calls.length > 0) {
345
+ result.tool_calls = message.tool_calls.map(toolCall => {
346
+ if (toolCall.type !== 'function') {
347
+ throw new Error(`Unsupported tool call type: ${toolCall.type}`);
348
+ }
349
+ const rawArguments = toolCall.function.arguments;
350
+ return {
351
+ id: toolCall.id,
352
+ type: 'function',
353
+ function: {
354
+ name: toolCall.function.name,
355
+ arguments: rawArguments.length > 0 ? rawArguments : '{}',
356
+ },
357
+ tool_name: toolCall.function.name,
358
+ arguments: parseArguments(rawArguments),
359
+ };
360
+ });
361
+ }
362
+
363
+ return result;
364
+ }
365
+
366
+ /**
367
+ * Streams the assistant reply. Each chunk holds a text delta, tool-call deltas, or usage.
368
+ */
369
+ async *streamChatCompletion(
370
+ messages: Message[],
371
+ options?: ChatCompletionOptions
372
+ ): AsyncGenerator<StreamChunk> {
373
+ const stream = await this.client.chat.completions.create({
374
+ ...this.baseRequest(),
375
+ messages: this.formatMessages(messages),
376
+ ...this.toolsParam(options?.temporaryTools ?? this.tools),
377
+ stream: true,
378
+ });
379
+
380
+ for await (const chunk of stream) {
381
+ const delta = chunk.choices[0]?.delta;
382
+
383
+ const text = delta?.content ?? '';
384
+ if (text !== '') {
385
+ yield { role: 'assistant', content: text };
386
+ }
387
+
388
+ if (delta?.tool_calls) {
389
+ yield {
390
+ role: 'assistant',
391
+ content: '',
392
+ tool_calls: delta.tool_calls.map(toolCall => {
393
+ const name = toolCall.function?.name ?? '';
394
+ const args = toolCall.function?.arguments ?? '';
395
+ return {
396
+ id: toolCall.id ?? '',
397
+ type: 'function',
398
+ function: { name, arguments: args },
399
+ tool_name: name,
400
+ arguments: args,
401
+ };
402
+ }),
403
+ };
404
+ }
405
+
406
+ if (chunk.usage) {
407
+ yield { role: 'assistant', content: '', usage: mapUsage(chunk.usage) };
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Runs the model outside a pipeline.
414
+ *
415
+ * @param input - A prompt string or a list of messages.
416
+ */
417
+ override run(input: string | Message[]): Promise<string | AssistantMessage> {
418
+ return typeof input === 'string' ? this.generateCompletion(input) : this.chatCompletion(input);
419
+ }
420
+
421
+ /**
422
+ * Connects the `prompt` and `messages` input ports to the model.
423
+ * API errors produce a fallback value so the pipeline keeps flowing.
424
+ */
425
+ override _transform($i: Port, $o: Port): void {
426
+ $i('prompt').receive((prompt: string) => {
427
+ this.generateCompletion(prompt).then(
428
+ completion => {
429
+ $o('completion').send(completion);
430
+ },
431
+ (error: unknown) => {
432
+ this.logError(error);
433
+ $o('completion').send(FALLBACK_TEXT);
434
+ }
435
+ );
436
+ });
437
+
438
+ $i('messages').receive((messages: Message[]) => {
439
+ this.chatCompletion(messages).then(
440
+ message => {
441
+ $o('message').send(message);
442
+ },
443
+ (error: unknown) => {
444
+ this.logError(error);
445
+ $o('message').send({ role: 'assistant', content: FALLBACK_TEXT });
446
+ }
447
+ );
448
+ });
449
+ }
450
+
451
+ private baseRequest(): {
452
+ model: string;
453
+ temperature: number;
454
+ max_tokens?: number;
455
+ top_p?: number;
456
+ } {
457
+ return {
458
+ model: this.model,
459
+ temperature: this.temperature,
460
+ ...(this.maxTokens === undefined ? {} : { max_tokens: this.maxTokens }),
461
+ ...(this.topP === undefined ? {} : { top_p: this.topP }),
462
+ };
463
+ }
464
+
465
+ private toolsParam(tools: unknown[] | undefined): { tools?: ChatTool[] } {
466
+ return tools !== undefined && tools.length > 0 ? { tools: tools.map(toApiTool) } : {};
467
+ }
468
+
469
+ private systemMessages(): ChatMessageParam[] {
470
+ return this.systemPrompt === undefined || this.systemPrompt === ''
471
+ ? []
472
+ : [{ role: 'system', content: this.systemPrompt }];
473
+ }
474
+
475
+ /**
476
+ * Converts input messages to API messages. Assistant `tool_calls` are not forwarded:
477
+ * agents send tool results back as user messages, and the API rejects assistant tool
478
+ * calls that are not followed by tool messages.
479
+ */
480
+ private formatMessages(messages: Message[]): ChatMessageParam[] {
481
+ return [...this.systemMessages(), ...messages.map(message => this.toApiMessage(message))];
482
+ }
483
+
484
+ /**
485
+ * Converts one input message to its API shape.
486
+ *
487
+ * @throws Error when the role is not one of `MessageRole`, or a `tool` message has no
488
+ * `tool_call_id`. The API would reject both.
489
+ */
490
+ private toApiMessage(message: Message): ChatMessageParam {
491
+ const content = message.content ?? '';
492
+ switch (message.role) {
493
+ case 'system':
494
+ return { role: 'system', content };
495
+ case 'user':
496
+ return { role: 'user', content };
497
+ case 'assistant':
498
+ return message.content === undefined
499
+ ? { role: 'assistant' }
500
+ : { role: 'assistant', content: message.content };
501
+ case 'tool':
502
+ if (message.tool_call_id === undefined || message.tool_call_id === '') {
503
+ throw new Error(
504
+ `${this.name}: a tool message needs the tool_call_id of the call it answers.`
505
+ );
506
+ }
507
+ return { role: 'tool', content, tool_call_id: message.tool_call_id };
508
+ default:
509
+ throw new Error(
510
+ `${this.name}: unsupported message role '${message.role}'. Use system, user, assistant or tool.`
511
+ );
512
+ }
513
+ }
514
+
515
+ private firstMessage(response: OpenAI.Chat.ChatCompletion): OpenAI.Chat.ChatCompletionMessage {
516
+ const choice = response.choices[0];
517
+ if (choice === undefined) {
518
+ throw new Error(`${this.name} returned no choices.`);
519
+ }
520
+ return choice.message;
521
+ }
522
+
523
+ private logError(error: unknown): void {
524
+ if (this.verbose) {
525
+ // eslint-disable-next-line no-console -- opt-in diagnostics controlled by `verbose`.
526
+ console.error(`[${this.name}] API call failed:`, error);
527
+ }
528
+ }
529
+ }
530
+
531
+ export default OpenAICompatible;