@luanpoppe/ai 1.1.5 → 1.1.6
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/.env.example +13 -1
- package/README.md +795 -0
- package/dist/@types/embedding-model-names.d.ts +11 -0
- package/dist/@types/embedding-model-names.d.ts.map +1 -0
- package/dist/@types/embedding-model-names.js +3 -0
- package/dist/@types/embedding-model-names.js.map +1 -0
- package/dist/@types/model-names.d.ts +5 -1
- package/dist/@types/model-names.d.ts.map +1 -1
- package/dist/@types/openrouter-provider.d.ts +25 -0
- package/dist/@types/openrouter-provider.d.ts.map +1 -0
- package/dist/@types/openrouter-provider.js +3 -0
- package/dist/@types/openrouter-provider.js.map +1 -0
- package/dist/ai.d.ts +7 -0
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +56 -5
- package/dist/ai.js.map +1 -1
- package/dist/audio/gemini-transcription.d.ts +4 -0
- package/dist/audio/gemini-transcription.d.ts.map +1 -0
- package/dist/audio/gemini-transcription.js +37 -0
- package/dist/audio/gemini-transcription.js.map +1 -0
- package/dist/audio/index.d.ts +33 -0
- package/dist/audio/index.d.ts.map +1 -0
- package/dist/audio/index.js +43 -0
- package/dist/audio/index.js.map +1 -0
- package/dist/audio/openrouter-client.d.ts +2 -0
- package/dist/audio/openrouter-client.d.ts.map +1 -0
- package/dist/audio/openrouter-client.js +7 -0
- package/dist/audio/openrouter-client.js.map +1 -0
- package/dist/audio/speech-openai.d.ts +4 -0
- package/dist/audio/speech-openai.d.ts.map +1 -0
- package/dist/audio/speech-openai.js +65 -0
- package/dist/audio/speech-openai.js.map +1 -0
- package/dist/audio/speech-openrouter.d.ts +4 -0
- package/dist/audio/speech-openrouter.d.ts.map +1 -0
- package/dist/audio/speech-openrouter.js +89 -0
- package/dist/audio/speech-openrouter.js.map +1 -0
- package/dist/audio/transcription-openai.d.ts +11 -0
- package/dist/audio/transcription-openai.d.ts.map +1 -0
- package/dist/audio/transcription-openai.js +204 -0
- package/dist/audio/transcription-openai.js.map +1 -0
- package/dist/audio/transcription-openrouter.d.ts +4 -0
- package/dist/audio/transcription-openrouter.d.ts.map +1 -0
- package/dist/audio/transcription-openrouter.js +44 -0
- package/dist/audio/transcription-openrouter.js.map +1 -0
- package/dist/audio/types.d.ts +92 -0
- package/dist/audio/types.d.ts.map +1 -0
- package/dist/audio/types.js +6 -0
- package/dist/audio/types.js.map +1 -0
- package/dist/audio/utils.d.ts +5 -0
- package/dist/audio/utils.d.ts.map +1 -0
- package/dist/audio/utils.js +25 -0
- package/dist/audio/utils.js.map +1 -0
- package/dist/embeddings/embed-gemini.d.ts +3 -0
- package/dist/embeddings/embed-gemini.d.ts.map +1 -0
- package/dist/embeddings/embed-gemini.js +38 -0
- package/dist/embeddings/embed-gemini.js.map +1 -0
- package/dist/embeddings/embed-openai.d.ts +3 -0
- package/dist/embeddings/embed-openai.d.ts.map +1 -0
- package/dist/embeddings/embed-openai.js +44 -0
- package/dist/embeddings/embed-openai.js.map +1 -0
- package/dist/embeddings/embed-openrouter.d.ts +3 -0
- package/dist/embeddings/embed-openrouter.d.ts.map +1 -0
- package/dist/embeddings/embed-openrouter.js +47 -0
- package/dist/embeddings/embed-openrouter.js.map +1 -0
- package/dist/embeddings/index.d.ts +19 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +36 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/route.d.ts +12 -0
- package/dist/embeddings/route.d.ts.map +1 -0
- package/dist/embeddings/route.js +42 -0
- package/dist/embeddings/route.js.map +1 -0
- package/dist/embeddings/types.d.ts +37 -0
- package/dist/embeddings/types.d.ts.map +1 -0
- package/dist/embeddings/types.js +6 -0
- package/dist/embeddings/types.js.map +1 -0
- package/dist/embeddings/utils.d.ts +6 -0
- package/dist/embeddings/utils.d.ts.map +1 -0
- package/dist/embeddings/utils.js +25 -0
- package/dist/embeddings/utils.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/langchain/audio-transcription.d.ts +8 -13
- package/dist/langchain/audio-transcription.d.ts.map +1 -1
- package/dist/langchain/audio-transcription.js +8 -92
- package/dist/langchain/audio-transcription.js.map +1 -1
- package/dist/langchain/messages.d.ts +6 -1
- package/dist/langchain/messages.d.ts.map +1 -1
- package/dist/langchain/messages.js +35 -7
- package/dist/langchain/messages.js.map +1 -1
- package/dist/langchain/models.d.ts +38 -0
- package/dist/langchain/models.d.ts.map +1 -1
- package/dist/langchain/models.js +63 -4
- package/dist/langchain/models.js.map +1 -1
- package/dist/langchain/structured-output.d.ts +9 -0
- package/dist/langchain/structured-output.d.ts.map +1 -0
- package/dist/langchain/structured-output.js +70 -0
- package/dist/langchain/structured-output.js.map +1 -0
- package/dist/langchain/tools.d.ts +1 -1
- package/dist/utils/openrouter-client.d.ts +4 -0
- package/dist/utils/openrouter-client.d.ts.map +1 -0
- package/dist/utils/openrouter-client.js +16 -0
- package/dist/utils/openrouter-client.js.map +1 -0
- package/docs/LANGCHAIN_WHISPER_WINDOWS_BUG.md +2 -0
- package/package.json +4 -4
- package/src/@types/embedding-model-names.ts +36 -0
- package/src/@types/model-names.ts +12 -1
- package/src/@types/openrouter-provider.ts +27 -0
- package/src/ai.ts +89 -10
- package/src/audio/gemini-transcription.ts +47 -0
- package/src/audio/index.ts +101 -0
- package/src/audio/openrouter-client.ts +4 -0
- package/src/audio/speech-openai.ts +79 -0
- package/src/audio/speech-openrouter.ts +121 -0
- package/src/audio/transcription-openai.ts +259 -0
- package/src/audio/transcription-openrouter.ts +59 -0
- package/src/audio/types.ts +127 -0
- package/src/audio/utils.ts +20 -0
- package/src/embeddings/embed-gemini.ts +54 -0
- package/src/embeddings/embed-openai.ts +53 -0
- package/src/embeddings/embed-openrouter.ts +64 -0
- package/src/embeddings/index.ts +83 -0
- package/src/embeddings/route.ts +62 -0
- package/src/embeddings/types.ts +53 -0
- package/src/embeddings/utils.ts +21 -0
- package/src/index.ts +51 -5
- package/src/langchain/audio-transcription.ts +19 -90
- package/src/langchain/messages.ts +84 -12
- package/src/langchain/models.ts +130 -3
- package/src/langchain/structured-output.ts +77 -0
- package/src/utils/openrouter-client.ts +10 -0
- package/tests/e2e/README.md +53 -0
- package/tests/e2e/ai-audio-multiprovider.test.ts +118 -0
- package/tests/e2e/ai-deepseek-structured-output.test.ts +133 -0
- package/tests/e2e/ai-embeddings.test.ts +87 -0
- package/tests/e2e/ai-local-models.test.ts +65 -0
- package/tests/unit/audio/index.test.ts +17 -0
- package/tests/unit/audio/speech-openai.test.ts +74 -0
- package/tests/unit/audio/speech-openrouter.test.ts +88 -0
- package/tests/unit/audio/transcription-openai.test.ts +130 -0
- package/tests/unit/audio/transcription-openrouter.test.ts +82 -0
- package/tests/unit/embeddings/embed-gemini.test.ts +52 -0
- package/tests/unit/embeddings/embed-openai.test.ts +68 -0
- package/tests/unit/embeddings/embed-openrouter.test.ts +62 -0
- package/tests/unit/embeddings/index.test.ts +55 -0
- package/tests/unit/index.test.ts +209 -0
- package/tests/unit/langchain/messages.test.ts +37 -0
- package/tests/unit/langchain/models.test.ts +223 -1
- package/tests/unit/langchain/structured-output.test.ts +49 -0
|
@@ -1,105 +1,34 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import OpenAI from "openai";
|
|
4
|
-
import { toFile } from "openai";
|
|
5
1
|
import type { AudioBuffer, AudioMimeType } from "../@types/audio";
|
|
6
|
-
import {
|
|
2
|
+
import { AIAudio } from "../audio";
|
|
3
|
+
import type {
|
|
4
|
+
OpenAITranscriptionOptions,
|
|
5
|
+
WhisperModel,
|
|
6
|
+
} from "../audio/types";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
export type WhisperModel =
|
|
10
|
-
| "whisper-1"
|
|
11
|
-
| "gpt-4o-transcribe"
|
|
12
|
-
| "gpt-4o-mini-transcribe"
|
|
13
|
-
| "gpt-4o-mini-transcribe-2025-12-15"
|
|
14
|
-
| "gpt-4o-transcribe-diarize";
|
|
8
|
+
export type { WhisperModel };
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
model?: WhisperModel;
|
|
19
|
-
languageIn2Digits?: string;
|
|
20
|
-
prompt?: string;
|
|
21
|
-
responseFormat?: "json" | "text" | "srt" | "verbose_json" | "vtt";
|
|
22
|
-
temperature?: number;
|
|
23
|
-
timestampGranularities?: ("word" | "segment")[];
|
|
24
|
-
/** Formato do áudio: extensão ("mp3", "wav", "webm") ou MIME type ("audio/wav", "audio/webm") */
|
|
25
|
-
format?: string | AudioMimeType;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
function getExtension(format?: string | AudioMimeType): string {
|
|
29
|
-
if (!format) return "mp3";
|
|
30
|
-
if (format.startsWith("audio/")) {
|
|
31
|
-
return MIME_TO_EXTENSION[format as AudioMimeType] ?? "mp3";
|
|
32
|
-
}
|
|
33
|
-
return format.replace(/^\./, "");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function toBuffer(audioBuffer: AudioBuffer): Buffer {
|
|
37
|
-
if (audioBuffer instanceof Buffer) return audioBuffer;
|
|
38
|
-
if (audioBuffer instanceof ArrayBuffer) return Buffer.from(audioBuffer);
|
|
39
|
-
return Buffer.from(audioBuffer as Uint8Array);
|
|
40
|
-
}
|
|
10
|
+
/** @deprecated Use OpenAITranscriptionOptions from AIAudio */
|
|
11
|
+
export type WhisperTranscriptionOptions = OpenAITranscriptionOptions;
|
|
41
12
|
|
|
13
|
+
/**
|
|
14
|
+
* @deprecated Use {@link AIAudio.transcribeWithWhisper} instead.
|
|
15
|
+
*/
|
|
42
16
|
export class AIAudioTranscription {
|
|
43
|
-
static
|
|
17
|
+
static transcribeWithWhisper(
|
|
44
18
|
audioBuffer: AudioBuffer,
|
|
45
19
|
options: WhisperTranscriptionOptions = {},
|
|
46
|
-
openAIApiKey?: string
|
|
20
|
+
openAIApiKey?: string,
|
|
47
21
|
): Promise<string> {
|
|
48
|
-
|
|
49
|
-
process.env.OPENAI_API_KEY = openAIApiKey;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const buffer = toBuffer(audioBuffer);
|
|
53
|
-
const extension = getExtension(options.format);
|
|
54
|
-
const fileName = `whisper-${Date.now()}.${extension}`;
|
|
55
|
-
|
|
56
|
-
const file = await toFile(buffer, fileName);
|
|
57
|
-
|
|
58
|
-
const openai = new OpenAI();
|
|
59
|
-
|
|
60
|
-
const transcriptionParams: OpenAI.Audio.TranscriptionCreateParams = {
|
|
61
|
-
file,
|
|
62
|
-
model: options.model ?? "whisper-1",
|
|
63
|
-
response_format: options.responseFormat ?? "text",
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
if (options.languageIn2Digits) {
|
|
67
|
-
transcriptionParams.language = options.languageIn2Digits;
|
|
68
|
-
}
|
|
69
|
-
if (options.prompt) {
|
|
70
|
-
transcriptionParams.prompt = options.prompt;
|
|
71
|
-
}
|
|
72
|
-
if (options.temperature !== undefined) {
|
|
73
|
-
transcriptionParams.temperature = options.temperature;
|
|
74
|
-
}
|
|
75
|
-
if (options.timestampGranularities) {
|
|
76
|
-
transcriptionParams.timestamp_granularities =
|
|
77
|
-
options.timestampGranularities;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const response = await openai.audio.transcriptions.create(
|
|
81
|
-
transcriptionParams
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
return typeof response === "string" ? response : response.text;
|
|
22
|
+
return AIAudio.transcribeWithWhisper(audioBuffer, options, openAIApiKey);
|
|
85
23
|
}
|
|
86
24
|
|
|
87
|
-
static
|
|
25
|
+
static transcribeFileWithWhisper(
|
|
88
26
|
filePath: string,
|
|
89
27
|
options: WhisperTranscriptionOptions = {},
|
|
90
|
-
openAIApiKey?: string
|
|
28
|
+
openAIApiKey?: string,
|
|
91
29
|
): Promise<string> {
|
|
92
|
-
|
|
93
|
-
throw new Error(`Arquivo não encontrado: ${filePath}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const audioBuffer = fs.readFileSync(filePath);
|
|
97
|
-
const format =
|
|
98
|
-
options.format ?? (path.extname(filePath).replace(/^\./, "") || "mp3");
|
|
99
|
-
return this.transcribeWithWhisper(
|
|
100
|
-
audioBuffer,
|
|
101
|
-
{ ...options, format },
|
|
102
|
-
openAIApiKey
|
|
103
|
-
);
|
|
30
|
+
return AIAudio.transcribeFileWithWhisper(filePath, options, openAIApiKey);
|
|
104
31
|
}
|
|
105
32
|
}
|
|
33
|
+
|
|
34
|
+
export type { AudioBuffer, AudioMimeType };
|
|
@@ -3,7 +3,7 @@ import { AudioUtils } from "../utils/audio-utils";
|
|
|
3
3
|
import { ImageUtils } from "../utils/image-utils";
|
|
4
4
|
import type { AudioBuffer, AudioMimeType } from "../@types/audio";
|
|
5
5
|
import type { ImageBuffer, ImageMimeType } from "../@types/image";
|
|
6
|
-
import {
|
|
6
|
+
import { AIAudio } from "../audio";
|
|
7
7
|
|
|
8
8
|
export type MessageInput = SystemMessage | HumanMessage | AIMessage;
|
|
9
9
|
|
|
@@ -39,8 +39,11 @@ export type HumanMessageWithAudioOptions = {
|
|
|
39
39
|
filename?: string;
|
|
40
40
|
};
|
|
41
41
|
text?: string;
|
|
42
|
-
provider?: "openai" | "gemini" | "auto";
|
|
42
|
+
provider?: "openai" | "gemini" | "openrouter" | "auto";
|
|
43
43
|
openAIApiKey?: string;
|
|
44
|
+
openRouterApiKey?: string;
|
|
45
|
+
/** Modelo OpenRouter STT (ex.: openai/whisper-1) quando provider é openrouter ou auto com OR */
|
|
46
|
+
openRouterTranscriptionModel?: string;
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
export class AIMessages {
|
|
@@ -83,16 +86,31 @@ export class AIMessages {
|
|
|
83
86
|
static async humanAudio(
|
|
84
87
|
options: HumanMessageWithAudioOptions
|
|
85
88
|
): Promise<HumanMessage> {
|
|
86
|
-
const {
|
|
89
|
+
const {
|
|
90
|
+
audio,
|
|
91
|
+
text,
|
|
92
|
+
provider = "auto",
|
|
93
|
+
openAIApiKey,
|
|
94
|
+
openRouterApiKey,
|
|
95
|
+
openRouterTranscriptionModel,
|
|
96
|
+
} = options;
|
|
87
97
|
const { buffer, mimeType, filename } = audio;
|
|
88
98
|
|
|
99
|
+
const resolvedProvider = this.resolveAudioProvider(
|
|
100
|
+
provider,
|
|
101
|
+
openAIApiKey,
|
|
102
|
+
openRouterApiKey,
|
|
103
|
+
);
|
|
104
|
+
|
|
89
105
|
return this.createHumanMessageWithAudio(
|
|
90
106
|
buffer,
|
|
91
107
|
text,
|
|
92
108
|
mimeType,
|
|
93
109
|
filename,
|
|
94
|
-
|
|
95
|
-
openAIApiKey
|
|
110
|
+
resolvedProvider,
|
|
111
|
+
openAIApiKey,
|
|
112
|
+
openRouterApiKey,
|
|
113
|
+
openRouterTranscriptionModel,
|
|
96
114
|
);
|
|
97
115
|
}
|
|
98
116
|
|
|
@@ -100,22 +118,45 @@ export class AIMessages {
|
|
|
100
118
|
return new AIMessage(message);
|
|
101
119
|
}
|
|
102
120
|
|
|
121
|
+
private static resolveAudioProvider(
|
|
122
|
+
provider: "openai" | "gemini" | "openrouter" | "auto",
|
|
123
|
+
openAIApiKey?: string,
|
|
124
|
+
openRouterApiKey?: string,
|
|
125
|
+
): "openai" | "gemini" | "openrouter" {
|
|
126
|
+
if (provider !== "auto") return provider;
|
|
127
|
+
if (openAIApiKey) return "openai";
|
|
128
|
+
if (openRouterApiKey) return "openrouter";
|
|
129
|
+
return "gemini";
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
private static async createHumanMessageWithAudio(
|
|
104
133
|
audioBuffer: AudioBuffer,
|
|
105
134
|
text?: string,
|
|
106
135
|
mimeType?: AudioMimeType,
|
|
107
136
|
filename?: string,
|
|
108
|
-
provider: "openai" | "gemini" | "
|
|
109
|
-
openAIApiKey?: string
|
|
137
|
+
provider: "openai" | "gemini" | "openrouter" = "gemini",
|
|
138
|
+
openAIApiKey?: string,
|
|
139
|
+
openRouterApiKey?: string,
|
|
140
|
+
openRouterTranscriptionModel = "openai/whisper-1",
|
|
110
141
|
): Promise<HumanMessage> {
|
|
111
|
-
|
|
112
|
-
if (provider === "openai")
|
|
142
|
+
if (provider === "openai") {
|
|
113
143
|
return await this.processAudioOpenAi(
|
|
114
144
|
audioBuffer,
|
|
115
145
|
mimeType,
|
|
116
146
|
text,
|
|
117
|
-
openAIApiKey
|
|
147
|
+
openAIApiKey,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (provider === "openrouter") {
|
|
152
|
+
return await this.processAudioOpenRouter(
|
|
153
|
+
audioBuffer,
|
|
154
|
+
mimeType,
|
|
155
|
+
text,
|
|
156
|
+
openRouterApiKey,
|
|
157
|
+
openRouterTranscriptionModel,
|
|
118
158
|
);
|
|
159
|
+
}
|
|
119
160
|
|
|
120
161
|
// Para Gemini e outros, usa multimodal direto (trabalho síncrono, mas retorna Promise para compatibilidade)
|
|
121
162
|
const base64Data = AudioUtils.bufferToBase64(audioBuffer);
|
|
@@ -168,10 +209,10 @@ export class AIMessages {
|
|
|
168
209
|
? { format: mimeType }
|
|
169
210
|
: { language: "pt" };
|
|
170
211
|
|
|
171
|
-
const transcribedText = await
|
|
212
|
+
const transcribedText = await AIAudio.transcribeWithWhisper(
|
|
172
213
|
audioBuffer,
|
|
173
214
|
transcriptionOptions,
|
|
174
|
-
openAIApiKey
|
|
215
|
+
openAIApiKey,
|
|
175
216
|
);
|
|
176
217
|
|
|
177
218
|
// Combina o texto original (se fornecido) com a transcrição
|
|
@@ -192,4 +233,35 @@ export class AIMessages {
|
|
|
192
233
|
);
|
|
193
234
|
}
|
|
194
235
|
}
|
|
236
|
+
|
|
237
|
+
private static async processAudioOpenRouter(
|
|
238
|
+
audioBuffer: AudioBuffer,
|
|
239
|
+
mimeType?: AudioMimeType,
|
|
240
|
+
text?: string,
|
|
241
|
+
openRouterApiKey?: string,
|
|
242
|
+
openRouterTranscriptionModel = "openai/whisper-1",
|
|
243
|
+
) {
|
|
244
|
+
if (!openRouterApiKey) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"openRouterApiKey é necessária quando provider é 'openrouter'.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const format = mimeType ?? AudioUtils.detectAudioMimeType(audioBuffer);
|
|
251
|
+
const { text: transcribedText } = await AIAudio.transcribeOpenRouter(
|
|
252
|
+
audioBuffer,
|
|
253
|
+
{
|
|
254
|
+
model: openRouterTranscriptionModel,
|
|
255
|
+
format,
|
|
256
|
+
...(text ? {} : { language: "pt" }),
|
|
257
|
+
},
|
|
258
|
+
openRouterApiKey,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const finalText = text
|
|
262
|
+
? `${text}\n\nÁudio transcrito: ${transcribedText}`
|
|
263
|
+
: `Áudio transcrito: ${transcribedText}`;
|
|
264
|
+
|
|
265
|
+
return new HumanMessage(finalText);
|
|
266
|
+
}
|
|
195
267
|
}
|
package/src/langchain/models.ts
CHANGED
|
@@ -2,20 +2,90 @@ import {
|
|
|
2
2
|
ChatGoogleGenerativeAI,
|
|
3
3
|
GoogleGenerativeAIChatInput,
|
|
4
4
|
} from "@langchain/google-genai";
|
|
5
|
+
import { ChatOllama, type ChatOllamaInput } from "@langchain/ollama";
|
|
5
6
|
import { ChatOpenAI, ChatOpenAIFields } from "@langchain/openai";
|
|
7
|
+
import type { OpenRouterProviderPreferences } from "../@types/openrouter-provider";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_LOCAL_API_KEY = "not-needed";
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
OpenRouterMaxPrice,
|
|
15
|
+
OpenRouterProviderPreferences,
|
|
16
|
+
OpenRouterProviderSort,
|
|
17
|
+
} from "../@types/openrouter-provider";
|
|
6
18
|
|
|
7
19
|
/** Nível de esforço de raciocínio para modelos OpenAI (o1, gpt-5, etc.). Valores: "low" | "medium" | "high" */
|
|
8
20
|
export type ReasoningEffort = "low" | "medium" | "high";
|
|
9
21
|
|
|
22
|
+
export type OllamaModelConfig = {
|
|
23
|
+
model: string;
|
|
24
|
+
baseUrl?: string | undefined;
|
|
25
|
+
maxTokens?: number | undefined;
|
|
26
|
+
temperature?: number | undefined;
|
|
27
|
+
numCtx?: number | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type OpenAICompatibleModelConfig = {
|
|
31
|
+
model: string;
|
|
32
|
+
baseURL: string;
|
|
33
|
+
apiKey?: string | undefined;
|
|
34
|
+
maxTokens?: number | undefined;
|
|
35
|
+
temperature?: number | undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
10
38
|
export type LLMModelConfig = {
|
|
11
39
|
model: string;
|
|
12
40
|
apiKey?: string | undefined;
|
|
41
|
+
/** Override da URL do servidor (Ollama nativo ou OpenAI-compatible local) */
|
|
42
|
+
baseUrl?: string | undefined;
|
|
13
43
|
maxTokens?: number | undefined;
|
|
14
44
|
temperature?: number | undefined;
|
|
45
|
+
/** Tamanho do contexto (Ollama nativo, `num_ctx`) */
|
|
46
|
+
numCtx?: number | undefined;
|
|
15
47
|
/** Nível de esforço de raciocínio (modelos OpenAI: o1, gpt-5, etc.) */
|
|
16
48
|
reasoningEffort?: ReasoningEffort | undefined;
|
|
49
|
+
/** Preferências de roteamento de providers do OpenRouter */
|
|
50
|
+
openRouterProvider?: OpenRouterProviderPreferences | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Se true, não aplica `only: ["deepseek"]` em modelos `deepseek/*` via OpenRouter.
|
|
53
|
+
* Use para aceitar o roteamento padrão do OpenRouter (todos os providers).
|
|
54
|
+
*/
|
|
55
|
+
openRouterAllowAllProviders?: boolean | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Força `response_format: { type: "json_object" }` no OpenRouter (ex.: DeepSeek).
|
|
58
|
+
* Uso interno via `callStructuredOutput`.
|
|
59
|
+
*/
|
|
60
|
+
openRouterForceJsonObject?: boolean | undefined;
|
|
17
61
|
};
|
|
18
62
|
|
|
63
|
+
const DEEPSEEK_OPENROUTER_PROVIDER: OpenRouterProviderPreferences = {
|
|
64
|
+
only: ["deepseek"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function resolveOpenRouterProvider(
|
|
68
|
+
model: string,
|
|
69
|
+
config: Pick<
|
|
70
|
+
LLMModelConfig,
|
|
71
|
+
"openRouterProvider" | "openRouterAllowAllProviders"
|
|
72
|
+
>,
|
|
73
|
+
): OpenRouterProviderPreferences | undefined {
|
|
74
|
+
if (config.openRouterProvider !== undefined) {
|
|
75
|
+
return config.openRouterProvider;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (config.openRouterAllowAllProviders === true) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (model.startsWith("deepseek/")) {
|
|
83
|
+
return DEEPSEEK_OPENROUTER_PROVIDER;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
19
89
|
export class AIModels {
|
|
20
90
|
static gpt(params: LLMModelConfig) {
|
|
21
91
|
const { model, apiKey, maxTokens, temperature, reasoningEffort } = params;
|
|
@@ -59,7 +129,16 @@ export class AIModels {
|
|
|
59
129
|
}
|
|
60
130
|
|
|
61
131
|
static openrouter(params: LLMModelConfig) {
|
|
62
|
-
const {
|
|
132
|
+
const {
|
|
133
|
+
apiKey,
|
|
134
|
+
maxTokens,
|
|
135
|
+
model,
|
|
136
|
+
temperature,
|
|
137
|
+
reasoningEffort,
|
|
138
|
+
openRouterProvider,
|
|
139
|
+
openRouterAllowAllProviders,
|
|
140
|
+
openRouterForceJsonObject,
|
|
141
|
+
} = params;
|
|
63
142
|
|
|
64
143
|
if (!apiKey)
|
|
65
144
|
throw new Error(
|
|
@@ -76,13 +155,61 @@ export class AIModels {
|
|
|
76
155
|
|
|
77
156
|
if (maxTokens) options.maxTokens = maxTokens;
|
|
78
157
|
if (temperature) options.temperature = temperature;
|
|
79
|
-
|
|
158
|
+
|
|
159
|
+
const provider = resolveOpenRouterProvider(model, {
|
|
160
|
+
openRouterProvider,
|
|
161
|
+
openRouterAllowAllProviders,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (reasoningEffort || provider || openRouterForceJsonObject) {
|
|
80
165
|
options.modelKwargs = {
|
|
81
166
|
...(options.modelKwargs ?? {}),
|
|
82
|
-
reasoning_effort: reasoningEffort,
|
|
167
|
+
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
|
168
|
+
...(provider ? { provider } : {}),
|
|
169
|
+
...(openRouterForceJsonObject
|
|
170
|
+
? { response_format: { type: "json_object" } }
|
|
171
|
+
: {}),
|
|
83
172
|
};
|
|
84
173
|
}
|
|
85
174
|
|
|
86
175
|
return new ChatOpenAI(options);
|
|
87
176
|
}
|
|
177
|
+
|
|
178
|
+
static ollama(params: OllamaModelConfig) {
|
|
179
|
+
const { model, baseUrl, maxTokens, temperature, numCtx } = params;
|
|
180
|
+
|
|
181
|
+
const options: ChatOllamaInput = {
|
|
182
|
+
model,
|
|
183
|
+
baseUrl: baseUrl ?? DEFAULT_OLLAMA_BASE_URL,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (temperature !== undefined) options.temperature = temperature;
|
|
187
|
+
if (numCtx !== undefined) options.numCtx = numCtx;
|
|
188
|
+
if (maxTokens !== undefined) options.numPredict = maxTokens;
|
|
189
|
+
|
|
190
|
+
return new ChatOllama(options);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
static openaiCompatible(params: OpenAICompatibleModelConfig) {
|
|
194
|
+
const { model, baseURL, apiKey, maxTokens, temperature } = params;
|
|
195
|
+
|
|
196
|
+
if (!baseURL) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"baseURL é obrigatório para servidores OpenAI-compatible (LM Studio, Ollama /v1, vLLM, etc.).",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const options: ChatOpenAIFields = {
|
|
203
|
+
model,
|
|
204
|
+
apiKey: apiKey ?? DEFAULT_LOCAL_API_KEY,
|
|
205
|
+
configuration: {
|
|
206
|
+
baseURL,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (maxTokens) options.maxTokens = maxTokens;
|
|
211
|
+
if (temperature !== undefined) options.temperature = temperature;
|
|
212
|
+
|
|
213
|
+
return new ChatOpenAI(options);
|
|
214
|
+
}
|
|
88
215
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { BaseMessage } from "langchain";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
/** Modelos DeepSeek (direto ou via OpenRouter) não aceitam `json_schema`; apenas `json_object`. */
|
|
5
|
+
export function isDeepSeekJsonObjectOnlyModel(aiModel: string): boolean {
|
|
6
|
+
return (
|
|
7
|
+
aiModel.startsWith("openrouter/deepseek/") || aiModel.startsWith("deepseek/")
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildJsonObjectSystemAppend(schema: z.ZodSchema): string {
|
|
12
|
+
const jsonSchema = z.toJSONSchema(schema);
|
|
13
|
+
return [
|
|
14
|
+
"",
|
|
15
|
+
"IMPORTANTE: Responda somente com um objeto JSON válido (sem markdown, sem texto extra).",
|
|
16
|
+
"O JSON deve seguir este schema:",
|
|
17
|
+
JSON.stringify(jsonSchema, null, 2),
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function mergeSystemPromptWithJsonSchema(
|
|
22
|
+
systemPrompt: string | undefined,
|
|
23
|
+
schema: z.ZodSchema,
|
|
24
|
+
): string {
|
|
25
|
+
const base = systemPrompt?.trim() ?? "";
|
|
26
|
+
const append = buildJsonObjectSystemAppend(schema);
|
|
27
|
+
return base ? `${base}\n${append}` : append.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function contentToString(content: unknown): string {
|
|
31
|
+
if (typeof content === "string") return content;
|
|
32
|
+
if (Array.isArray(content)) {
|
|
33
|
+
return content
|
|
34
|
+
.map((part) => {
|
|
35
|
+
if (typeof part === "string") return part;
|
|
36
|
+
if (part && typeof part === "object" && "text" in part) {
|
|
37
|
+
return String((part as { text?: unknown }).text ?? "");
|
|
38
|
+
}
|
|
39
|
+
return "";
|
|
40
|
+
})
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extrai e faz parse de JSON na última mensagem do assistente (suporta fences ```json). */
|
|
47
|
+
export function parseJsonFromAssistantMessages(
|
|
48
|
+
messages: BaseMessage[],
|
|
49
|
+
): unknown {
|
|
50
|
+
const last = messages.at(-1);
|
|
51
|
+
if (!last) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"[@luanpoppe/ai] Nenhuma mensagem do assistente para extrair JSON.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const raw = contentToString((last as { content?: unknown }).content).trim();
|
|
58
|
+
if (!raw) {
|
|
59
|
+
throw new Error("[@luanpoppe/ai] Resposta vazia ao extrair JSON estruturado.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fenced = raw.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
|
|
63
|
+
const jsonText = (fenced?.[1] ?? raw).trim();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(jsonText);
|
|
67
|
+
} catch {
|
|
68
|
+
const start = jsonText.indexOf("{");
|
|
69
|
+
const end = jsonText.lastIndexOf("}");
|
|
70
|
+
if (start >= 0 && end > start) {
|
|
71
|
+
return JSON.parse(jsonText.slice(start, end + 1));
|
|
72
|
+
}
|
|
73
|
+
throw new Error(
|
|
74
|
+
"[@luanpoppe/ai] Não foi possível fazer parse do JSON retornado pelo modelo.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
package/tests/e2e/README.md
CHANGED
|
@@ -25,6 +25,59 @@ O arquivo `.env` é carregado automaticamente pelo `dotenv` configurado no `vite
|
|
|
25
25
|
|
|
26
26
|
## Executando os testes
|
|
27
27
|
|
|
28
|
+
### DeepSeek structured output
|
|
29
|
+
```bash
|
|
30
|
+
pnpm test:e2e -- ai-deepseek-structured-output
|
|
31
|
+
```
|
|
32
|
+
Requer `OPENROUTER_API_KEY`. Valida `callStructuredOutput` com `openrouter/deepseek/deepseek-v4-flash` e `deepseek-v4-pro`.
|
|
33
|
+
|
|
34
|
+
### Modelos locais (Ollama + LM Studio / OpenAI-compatible)
|
|
35
|
+
```bash
|
|
36
|
+
pnpm test:e2e -- ai-local-models
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Ollama (prefixo `ollama/`, API nativa):**
|
|
40
|
+
1. Instale e inicie o Ollama (`ollama serve`)
|
|
41
|
+
2. Baixe um modelo: `ollama pull llama3.2`
|
|
42
|
+
3. No `.env`: `OLLAMA_E2E=1`, opcionalmente `OLLAMA_BASE_URL` e `OLLAMA_MODEL_NAME`
|
|
43
|
+
|
|
44
|
+
**LM Studio / Ollama `/v1` (prefixo `local/`):**
|
|
45
|
+
1. Ative o servidor local compatível com OpenAI (ex.: LM Studio em `http://localhost:1234/v1`)
|
|
46
|
+
2. No `.env`: `LOCAL_BASE_URL=http://localhost:1234/v1` e `LOCAL_MODEL_NAME` com o nome exato do modelo no servidor
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
const ai = new AI({
|
|
50
|
+
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
51
|
+
localBaseUrl: "http://localhost:1234/v1",
|
|
52
|
+
localApiKey: "not-needed",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await ai.call({ aiModel: "ollama/llama3.2", messages: [...] });
|
|
56
|
+
await ai.call({ aiModel: "local/seu-modelo", messages: [...] });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Limitações: tools e structured output dependem do modelo local; áudio (STT/TTS) continua via `AIAudio` na nuvem.
|
|
60
|
+
|
|
61
|
+
### Embeddings (`AIEmbeddings`)
|
|
62
|
+
```bash
|
|
63
|
+
pnpm test:e2e -- ai-embeddings
|
|
64
|
+
```
|
|
65
|
+
- OpenAI: `OPENAI_API_KEY` — `text-embedding-3-small`
|
|
66
|
+
- Gemini: `GOOGLE_GEMINI_TOKEN` — `gemini-embedding-001`
|
|
67
|
+
- OpenRouter: `OPENROUTER_API_KEY` — `openrouter/openai/text-embedding-3-small`
|
|
68
|
+
|
|
69
|
+
Listar modelos: `GET https://openrouter.ai/api/v1/embeddings/models`
|
|
70
|
+
|
|
71
|
+
### STT/TTS multiprovider (`AIAudio`)
|
|
72
|
+
```bash
|
|
73
|
+
pnpm test:e2e -- ai-audio-multiprovider
|
|
74
|
+
```
|
|
75
|
+
- OpenAI: `OPENAI_API_KEY` (STT detailed + TTS)
|
|
76
|
+
- OpenRouter: `OPENROUTER_API_KEY` (STT + TTS)
|
|
77
|
+
- TTS Gemini: `google/gemini-3.1-flash-tts-preview` + `Kore` + `pcm`
|
|
78
|
+
- TTS Mistral: `mistralai/voxtral-mini-tts-2603` + `en_paul_neutral` + `mp3` (exige providers liberados na conta OR)
|
|
79
|
+
- Listar modelos TTS: `GET https://openrouter.ai/api/v1/models?output_modalities=speech`
|
|
80
|
+
|
|
28
81
|
### Todos os testes E2E
|
|
29
82
|
```bash
|
|
30
83
|
pnpm test:e2e
|