outlet-orm 6.5.0 → 9.0.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.
Files changed (58) hide show
  1. package/README.md +130 -2
  2. package/bin/init.js +122 -0
  3. package/bin/mcp.js +78 -0
  4. package/bin/migrate.js +25 -0
  5. package/docs/skills/outlet-orm/ADVANCED.md +575 -0
  6. package/docs/skills/outlet-orm/AI.md +220 -0
  7. package/docs/skills/outlet-orm/API.md +522 -0
  8. package/docs/skills/outlet-orm/BACKUP.md +150 -0
  9. package/docs/skills/outlet-orm/MIGRATIONS.md +605 -0
  10. package/docs/skills/outlet-orm/MODELS.md +427 -0
  11. package/docs/skills/outlet-orm/QUERIES.md +345 -0
  12. package/docs/skills/outlet-orm/RELATIONS.md +555 -0
  13. package/docs/skills/outlet-orm/SECURITY.md +386 -0
  14. package/docs/skills/outlet-orm/SEEDS.md +98 -0
  15. package/docs/skills/outlet-orm/SKILL.md +205 -0
  16. package/docs/skills/outlet-orm/TYPESCRIPT.md +480 -0
  17. package/package.json +7 -3
  18. package/src/AI/AIPromptEnhancer.js +170 -0
  19. package/src/AI/AIQueryBuilder.js +234 -0
  20. package/src/AI/AIQueryOptimizer.js +185 -0
  21. package/src/AI/AISafetyGuardrails.js +146 -0
  22. package/src/AI/AISeeder.js +181 -0
  23. package/src/AI/AiBridgeManager.js +287 -0
  24. package/src/AI/Builders/TextBuilder.js +170 -0
  25. package/src/AI/Contracts/AudioProviderContract.js +29 -0
  26. package/src/AI/Contracts/ChatProviderContract.js +38 -0
  27. package/src/AI/Contracts/EmbeddingsProviderContract.js +19 -0
  28. package/src/AI/Contracts/ImageProviderContract.js +19 -0
  29. package/src/AI/Contracts/ModelsProviderContract.js +26 -0
  30. package/src/AI/Contracts/ToolContract.js +25 -0
  31. package/src/AI/Facades/AiBridge.js +79 -0
  32. package/src/AI/MCPServer.js +798 -0
  33. package/src/AI/PromptGenerator.js +318 -0
  34. package/src/AI/Providers/ClaudeProvider.js +64 -0
  35. package/src/AI/Providers/CustomOpenAIProvider.js +238 -0
  36. package/src/AI/Providers/GeminiProvider.js +68 -0
  37. package/src/AI/Providers/GrokProvider.js +46 -0
  38. package/src/AI/Providers/MistralProvider.js +21 -0
  39. package/src/AI/Providers/OllamaProvider.js +249 -0
  40. package/src/AI/Providers/OllamaTurboProvider.js +32 -0
  41. package/src/AI/Providers/OnnProvider.js +46 -0
  42. package/src/AI/Providers/OpenAIProvider.js +471 -0
  43. package/src/AI/Support/AudioNormalizer.js +37 -0
  44. package/src/AI/Support/ChatNormalizer.js +42 -0
  45. package/src/AI/Support/Document.js +77 -0
  46. package/src/AI/Support/DocumentAttachmentMapper.js +101 -0
  47. package/src/AI/Support/EmbeddingsNormalizer.js +30 -0
  48. package/src/AI/Support/Exceptions/ProviderError.js +22 -0
  49. package/src/AI/Support/FileSecurity.js +56 -0
  50. package/src/AI/Support/ImageNormalizer.js +62 -0
  51. package/src/AI/Support/JsonSchemaValidator.js +73 -0
  52. package/src/AI/Support/Message.js +40 -0
  53. package/src/AI/Support/StreamChunk.js +45 -0
  54. package/src/AI/Support/ToolChatRunner.js +160 -0
  55. package/src/AI/Support/ToolRegistry.js +62 -0
  56. package/src/AI/Tools/SystemInfoTool.js +25 -0
  57. package/src/index.js +77 -1
  58. package/types/index.d.ts +432 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Outlet ORM — Prompt-based Model Generator
3
+ * Parses natural language descriptions and generates models + migrations.
4
+ *
5
+ * Used by `outlet-init --prompt "..."` to bootstrap projects from descriptions.
6
+ *
7
+ * @since 7.0.0
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ // ─── Domain pattern recognition ──────────────────────────────────
14
+
15
+ const DOMAIN_PATTERNS = {
16
+ // E-commerce
17
+ 'e-?commerce|shop|store|product|cart|order|payment|checkout': {
18
+ tables: {
19
+ users: { columns: ['name:string', 'email:string:unique', 'password:string', 'role:string:default(customer)'] },
20
+ products: { columns: ['name:string', 'description:text:nullable', 'price:decimal(10,2)', 'stock:integer:default(0)', 'sku:string:unique', 'category_id:foreignId'] },
21
+ categories: { columns: ['name:string', 'slug:string:unique', 'parent_id:integer:nullable'] },
22
+ orders: { columns: ['user_id:foreignId', 'status:string:default(pending)', 'total:decimal(10,2)', 'shipping_address:text'] },
23
+ order_items:{ columns: ['order_id:foreignId', 'product_id:foreignId', 'quantity:integer', 'price:decimal(10,2)'] },
24
+ payments: { columns: ['order_id:foreignId', 'method:string', 'amount:decimal(10,2)', 'status:string:default(pending)', 'transaction_id:string:nullable'] }
25
+ }
26
+ },
27
+
28
+ // Blog / CMS
29
+ 'blog|article|post|cms|content|comment|tag': {
30
+ tables: {
31
+ users: { columns: ['name:string', 'email:string:unique', 'password:string', 'bio:text:nullable', 'avatar:string:nullable'] },
32
+ posts: { columns: ['user_id:foreignId', 'title:string', 'slug:string:unique', 'content:text', 'excerpt:text:nullable', 'status:string:default(draft)', 'published_at:timestamp:nullable'] },
33
+ categories: { columns: ['name:string', 'slug:string:unique', 'description:text:nullable'] },
34
+ tags: { columns: ['name:string', 'slug:string:unique'] },
35
+ post_tag: { columns: ['post_id:foreignId', 'tag_id:foreignId'], pivot: true },
36
+ comments: { columns: ['post_id:foreignId', 'user_id:foreignId:nullable', 'author_name:string:nullable', 'body:text', 'approved:boolean:default(false)'] }
37
+ }
38
+ },
39
+
40
+ // Task / Project management
41
+ 'task|project|todo|kanban|board|sprint|ticket': {
42
+ tables: {
43
+ users: { columns: ['name:string', 'email:string:unique', 'password:string', 'avatar:string:nullable'] },
44
+ projects: { columns: ['name:string', 'description:text:nullable', 'owner_id:foreignId', 'status:string:default(active)'] },
45
+ tasks: { columns: ['project_id:foreignId', 'assigned_to:integer:nullable', 'title:string', 'description:text:nullable', 'status:string:default(todo)', 'priority:string:default(medium)', 'due_date:date:nullable'] },
46
+ labels: { columns: ['name:string', 'color:string:default(#3498db)'] },
47
+ task_label: { columns: ['task_id:foreignId', 'label_id:foreignId'], pivot: true }
48
+ }
49
+ },
50
+
51
+ // Social network
52
+ 'social|friend|follow|like|feed|profile|message|chat': {
53
+ tables: {
54
+ users: { columns: ['name:string', 'email:string:unique', 'password:string', 'username:string:unique', 'bio:text:nullable', 'avatar:string:nullable'] },
55
+ posts: { columns: ['user_id:foreignId', 'content:text', 'media_url:string:nullable', 'visibility:string:default(public)'] },
56
+ comments: { columns: ['post_id:foreignId', 'user_id:foreignId', 'body:text'] },
57
+ likes: { columns: ['user_id:foreignId', 'likeable_id:integer', 'likeable_type:string'] },
58
+ follows: { columns: ['follower_id:foreignId', 'following_id:foreignId'] },
59
+ messages: { columns: ['sender_id:foreignId', 'receiver_id:foreignId', 'body:text', 'read_at:timestamp:nullable'] }
60
+ }
61
+ },
62
+
63
+ // SaaS / Multi-tenant
64
+ 'saas|tenant|subscription|plan|billing|organization': {
65
+ tables: {
66
+ organizations: { columns: ['name:string', 'slug:string:unique', 'plan_id:foreignId:nullable'] },
67
+ users: { columns: ['organization_id:foreignId', 'name:string', 'email:string:unique', 'password:string', 'role:string:default(member)'] },
68
+ plans: { columns: ['name:string', 'slug:string:unique', 'price:decimal(8,2)', 'features:json:nullable', 'max_users:integer:default(5)'] },
69
+ subscriptions: { columns: ['organization_id:foreignId', 'plan_id:foreignId', 'status:string:default(active)', 'starts_at:timestamp', 'ends_at:timestamp:nullable', 'trial_ends_at:timestamp:nullable'] },
70
+ invoices: { columns: ['subscription_id:foreignId', 'amount:decimal(10,2)', 'status:string:default(pending)', 'paid_at:timestamp:nullable'] }
71
+ }
72
+ },
73
+
74
+ // Habit tracker / Health
75
+ 'habit|tracker|health|fitness|goal|streak|log': {
76
+ tables: {
77
+ users: { columns: ['name:string', 'email:string:unique', 'password:string', 'timezone:string:default(UTC)'] },
78
+ habits: { columns: ['user_id:foreignId', 'name:string', 'description:text:nullable', 'frequency:string:default(daily)', 'color:string:default(#3498db)', 'target:integer:default(1)'] },
79
+ logs: { columns: ['habit_id:foreignId', 'date:date', 'completed:boolean:default(false)', 'value:integer:default(0)', 'notes:text:nullable'] },
80
+ goals: { columns: ['user_id:foreignId', 'title:string', 'target_value:integer', 'current_value:integer:default(0)', 'deadline:date:nullable'] }
81
+ }
82
+ },
83
+
84
+ // API / Auth / Generic
85
+ 'api|auth|rest|user': {
86
+ tables: {
87
+ users: { columns: ['name:string', 'email:string:unique', 'password:string', 'role:string:default(user)', 'email_verified_at:timestamp:nullable'] },
88
+ tokens: { columns: ['user_id:foreignId', 'token:string:unique', 'type:string:default(api)', 'expires_at:timestamp:nullable'] },
89
+ password_resets:{ columns: ['email:string', 'token:string', 'created_at:timestamp'] }
90
+ }
91
+ }
92
+ };
93
+
94
+ // ─── Generator ──────────────────────────────────────────────────
95
+
96
+ class PromptGenerator {
97
+ /**
98
+ * Parse a natural language prompt and return a project blueprint.
99
+ * @param {string} prompt - e.g. "Create a blog application with comments and tags"
100
+ * @returns {{ domain: string, tables: object }}
101
+ */
102
+ static parse(prompt) {
103
+ const lower = prompt.toLowerCase();
104
+ let bestMatch = null;
105
+ let bestScore = 0;
106
+
107
+ for (const [pattern, blueprint] of Object.entries(DOMAIN_PATTERNS)) {
108
+ const regex = new RegExp(pattern, 'i');
109
+ const keywords = pattern.split('|').map(k => k.replace(/[^a-z]/g, ''));
110
+ let score = 0;
111
+
112
+ for (const kw of keywords) {
113
+ if (lower.includes(kw)) score++;
114
+ }
115
+ if (regex.test(lower)) score += 2;
116
+
117
+ if (score > bestScore) {
118
+ bestScore = score;
119
+ bestMatch = { pattern, blueprint, score };
120
+ }
121
+ }
122
+
123
+ // Default to generic API/Auth if no match
124
+ if (!bestMatch || bestScore < 1) {
125
+ bestMatch = { pattern: 'api|auth', blueprint: DOMAIN_PATTERNS['api|auth|rest|user'], score: 0 };
126
+ }
127
+
128
+ return {
129
+ domain: bestMatch.pattern.split('|')[0],
130
+ tables: bestMatch.blueprint.tables,
131
+ score: bestMatch.score
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Generate model files from a blueprint.
137
+ * @param {{ tables: object }} blueprint
138
+ * @param {string} outputDir - e.g. process.cwd() + '/models'
139
+ * @returns {string[]} created file paths
140
+ */
141
+ static generateModels(blueprint, outputDir) {
142
+ fs.mkdirSync(outputDir, { recursive: true });
143
+ const created = [];
144
+
145
+ for (const [tableName, config] of Object.entries(blueprint.tables)) {
146
+ if (config.pivot) continue; // Skip pivot tables for model files
147
+
148
+ const className = this._toClassName(tableName);
149
+ const fillable = config.columns
150
+ .map(c => c.split(':')[0])
151
+ .filter(c => !['id', 'created_at', 'updated_at'].includes(c));
152
+
153
+ const hidden = fillable.filter(c => c === 'password' || c.includes('token') || c.includes('secret'));
154
+
155
+ const content = `const { Model } = require('outlet-orm');
156
+
157
+ class ${className} extends Model {
158
+ static table = '${tableName}';
159
+ static fillable = ${JSON.stringify(fillable)};${hidden.length > 0 ? `\n static hidden = ${JSON.stringify(hidden)};` : ''}
160
+ }
161
+
162
+ module.exports = ${className};
163
+ `;
164
+
165
+ const filePath = path.join(outputDir, `${className}.js`);
166
+ fs.writeFileSync(filePath, content);
167
+ created.push(filePath);
168
+ }
169
+
170
+ return created;
171
+ }
172
+
173
+ /**
174
+ * Generate migration files from a blueprint.
175
+ * @param {{ tables: object }} blueprint
176
+ * @param {string} outputDir - e.g. process.cwd() + '/database/migrations'
177
+ * @returns {string[]} created file paths
178
+ */
179
+ static generateMigrations(blueprint, outputDir) {
180
+ fs.mkdirSync(outputDir, { recursive: true });
181
+ const created = [];
182
+ let index = 0;
183
+
184
+ for (const [tableName, config] of Object.entries(blueprint.tables)) {
185
+ index++;
186
+ const timestamp = new Date(Date.now() + index * 1000)
187
+ .toISOString()
188
+ .replace(/[-:]/g, '')
189
+ .replace(/T/, '_')
190
+ .replace(/\..+/, '');
191
+
192
+ const className = `Create${this._toClassName(tableName)}Table`;
193
+ const fileName = `${timestamp}_create_${tableName}_table.js`;
194
+ const filePath = path.join(outputDir, fileName);
195
+
196
+ const columnDefs = config.columns.map(c => this._columnToSchema(c)).join('\n ');
197
+
198
+ const content = `const { Migration } = require('outlet-orm');
199
+
200
+ class ${className} extends Migration {
201
+ async up() {
202
+ const schema = this.getSchema();
203
+ await schema.create('${tableName}', (table) => {
204
+ table.id();
205
+ ${columnDefs}
206
+ table.timestamps();
207
+ });
208
+ }
209
+
210
+ async down() {
211
+ const schema = this.getSchema();
212
+ await schema.dropIfExists('${tableName}');
213
+ }
214
+ }
215
+
216
+ module.exports = ${className};
217
+ `;
218
+
219
+ fs.writeFileSync(filePath, content);
220
+ created.push(filePath);
221
+ }
222
+
223
+ return created;
224
+ }
225
+
226
+ /**
227
+ * Generate a seed file from a blueprint.
228
+ * @param {{ tables: object }} blueprint
229
+ * @param {string} outputDir
230
+ * @returns {string}
231
+ */
232
+ static generateSeeder(blueprint, outputDir) {
233
+ fs.mkdirSync(outputDir, { recursive: true });
234
+ const tables = Object.keys(blueprint.tables);
235
+
236
+ const seedCalls = tables
237
+ .filter(t => !blueprint.tables[t].pivot)
238
+ .map(t => ` // await this.call('${this._toClassName(t)}Seeder');`)
239
+ .join('\n');
240
+
241
+ const content = `const { Seeder } = require('outlet-orm');
242
+
243
+ class DatabaseSeeder extends Seeder {
244
+ async run() {
245
+ ${seedCalls}
246
+ }
247
+ }
248
+
249
+ module.exports = DatabaseSeeder;
250
+ `;
251
+
252
+ const filePath = path.join(outputDir, 'DatabaseSeeder.js');
253
+ fs.writeFileSync(filePath, content);
254
+ return filePath;
255
+ }
256
+
257
+ // ─── Helpers ───────────────────────────────────────────────────
258
+
259
+ static _toClassName(tableName) {
260
+ // users -> User, order_items -> OrderItem
261
+ return tableName
262
+ .split('_')
263
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
264
+ .join('')
265
+ .replace(/s$/, ''); // naive singularize (plural -> singular)
266
+ }
267
+
268
+ static _columnToSchema(columnDef) {
269
+ // Format: name:type:modifier1:modifier2
270
+ // e.g. "email:string:unique", "price:decimal(10,2)", "status:string:default(pending)"
271
+ const parts = columnDef.split(':');
272
+ const name = parts[0];
273
+ const type = parts[1] || 'string';
274
+ const modifiers = parts.slice(2);
275
+
276
+ // Parse type and arguments
277
+ const typeMatch = type.match(/^(\w+)(?:\((.+)\))?$/);
278
+ const typeName = typeMatch ? typeMatch[1] : type;
279
+ const typeArgs = typeMatch && typeMatch[2] ? `, ${typeMatch[2]}` : '';
280
+
281
+ // Map to schema builder methods
282
+ const typeMap = {
283
+ string: 'string',
284
+ text: 'text',
285
+ integer: 'integer',
286
+ int: 'integer',
287
+ boolean: 'boolean',
288
+ bool: 'boolean',
289
+ decimal: 'decimal',
290
+ float: 'float',
291
+ date: 'date',
292
+ timestamp: 'timestamp',
293
+ json: 'json',
294
+ foreignId: 'integer'
295
+ };
296
+
297
+ const schemaType = typeMap[typeName] || 'string';
298
+ let line = `table.${schemaType}('${name}'${typeArgs})`;
299
+
300
+ // Apply modifiers
301
+ for (const mod of modifiers) {
302
+ if (mod === 'unique') line += '.unique()';
303
+ else if (mod === 'nullable') line += '.nullable()';
304
+ else if (mod.startsWith('default(')) {
305
+ const val = mod.match(/default\((.+)\)/)?.[1] || '';
306
+ // Smart quoting: don't quote numbers or booleans
307
+ const isNum = !isNaN(val);
308
+ const isBool = val === 'true' || val === 'false';
309
+ const quoted = isNum || isBool ? val : `'${val}'`;
310
+ line += `.default(${quoted})`;
311
+ }
312
+ }
313
+
314
+ return line + ';';
315
+ }
316
+ }
317
+
318
+ module.exports = PromptGenerator;
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ClaudeProvider
5
+ * Anthropic Messages API. System messages are converted to user role.
6
+ * Streaming is simulated via chunk-splitting (60-char chunks).
7
+ */
8
+ class ClaudeProvider {
9
+ /**
10
+ * @param {string} apiKey
11
+ * @param {string} [endpoint='https://api.anthropic.com/v1/messages']
12
+ */
13
+ constructor(apiKey, endpoint = 'https://api.anthropic.com/v1/messages') {
14
+ this.apiKey = apiKey;
15
+ this.endpoint = endpoint;
16
+ }
17
+
18
+ /** @private */
19
+ _headers() {
20
+ return {
21
+ 'x-api-key': this.apiKey,
22
+ 'anthropic-version': '2023-06-01',
23
+ 'Content-Type': 'application/json',
24
+ 'Accept': 'application/json',
25
+ };
26
+ }
27
+
28
+ async chat(messages, options = {}) {
29
+ // Convert system messages to user role (Claude requirement)
30
+ const converted = messages.map(m => {
31
+ if ((m.role || '') === 'system') return { role: 'user', content: m.content };
32
+ return m;
33
+ });
34
+
35
+ const payload = {
36
+ model: options.model || 'claude-3-opus-20240229',
37
+ max_tokens: options.max_tokens || 512,
38
+ messages: converted,
39
+ };
40
+ if (options.temperature !== undefined) payload.temperature = options.temperature;
41
+
42
+ const res = await fetch(this.endpoint, {
43
+ method: 'POST',
44
+ headers: this._headers(),
45
+ body: JSON.stringify(payload),
46
+ });
47
+ const data = await res.json();
48
+ return data || {};
49
+ }
50
+
51
+ async *stream(messages, options = {}) {
52
+ const full = await this.chat(messages, options);
53
+ let text = '';
54
+ if (full?.content?.[0]?.text) text = full.content[0].text;
55
+ // Simulated: yield 60-char chunks
56
+ for (let i = 0; i < text.length; i += 60) {
57
+ yield text.slice(i, i + 60);
58
+ }
59
+ }
60
+
61
+ supportsStreaming() { return true; } // simulated
62
+ }
63
+
64
+ module.exports = ClaudeProvider;
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const JsonSchemaValidator = require('../Support/JsonSchemaValidator');
4
+
5
+ /**
6
+ * CustomOpenAIProvider
7
+ * Fully configurable OpenAI-compatible provider.
8
+ * Works with Azure OpenAI, proxies, OpenRouter, self-hosted endpoints, etc.
9
+ * Supports chat, streaming (SSE), embeddings, images, audio TTS/STT, and models.
10
+ */
11
+ class CustomOpenAIProvider {
12
+ /**
13
+ * @param {string} apiKey
14
+ * @param {string} baseUrl
15
+ * @param {Object} [paths={}]
16
+ * @param {string} [authHeader='Authorization']
17
+ * @param {string} [authPrefix='Bearer ']
18
+ * @param {Object} [extraHeaders={}]
19
+ */
20
+ constructor(apiKey, baseUrl, paths = {}, authHeader = 'Authorization', authPrefix = 'Bearer ', extraHeaders = {}) {
21
+ this.apiKey = apiKey;
22
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
23
+ this.paths = paths;
24
+ this.authHeader = authHeader;
25
+ this.authPrefix = authPrefix;
26
+ this.extraHeaders = extraHeaders;
27
+ }
28
+
29
+ /** @private */
30
+ _endpoint(key) {
31
+ const p = this.paths[key] || '';
32
+ return this.baseUrl + p;
33
+ }
34
+
35
+ /** @private */
36
+ _headers() {
37
+ return Object.assign({
38
+ [this.authHeader]: this.authPrefix + this.apiKey,
39
+ 'Content-Type': 'application/json',
40
+ 'Accept': 'application/json',
41
+ }, this.extraHeaders);
42
+ }
43
+
44
+ /** @private */
45
+ async _post(url, body, stream = false) {
46
+ const opts = { method: 'POST', headers: this._headers(), body: JSON.stringify(body) };
47
+ if (stream) return fetch(url, opts);
48
+ const res = await fetch(url, opts);
49
+ return res.json();
50
+ }
51
+
52
+ /** @private */
53
+ async _get(url) {
54
+ const res = await fetch(url, { method: 'GET', headers: this._headers() });
55
+ return res.json();
56
+ }
57
+
58
+ // ─── Chat ───
59
+ async chat(messages, options = {}) {
60
+ const payload = this._buildChatPayload(messages, options);
61
+ const res = await this._post(this._endpoint('chat'), payload);
62
+ this._normalizeToolCallsOnResponse(res);
63
+ return res || {};
64
+ }
65
+
66
+ /** @private */
67
+ _buildChatPayload(messages, options) {
68
+ const payload = {
69
+ model: options.model || options.deployment || 'gpt-like',
70
+ messages,
71
+ };
72
+ this._applySamplingOptions(payload, options);
73
+ this._applyResponseFormatOptions(payload, options);
74
+ this._applyToolsOptions(payload, options);
75
+ return payload;
76
+ }
77
+
78
+ /** @private */
79
+ _applySamplingOptions(payload, options) {
80
+ for (const k of ['temperature', 'top_p', 'max_tokens', 'frequency_penalty', 'presence_penalty', 'stop', 'seed', 'user']) {
81
+ if (options[k] !== undefined) payload[k] = options[k];
82
+ }
83
+ }
84
+
85
+ /** @private */
86
+ _applyResponseFormatOptions(payload, options) {
87
+ if (options.response_format === 'json') {
88
+ const schema = (options.json_schema || {}).schema || { type: 'object' };
89
+ payload.response_format = {
90
+ type: 'json_schema',
91
+ json_schema: options.json_schema || { name: 'auto_schema', schema },
92
+ };
93
+ }
94
+ }
95
+
96
+ /** @private */
97
+ _applyToolsOptions(payload, options) {
98
+ if (!options.tools || !Array.isArray(options.tools)) return;
99
+ payload.tools = options.tools.map(tool => ({
100
+ type: 'function',
101
+ function: {
102
+ name: tool.name,
103
+ description: tool.description || '',
104
+ parameters: tool.parameters || tool.schema || { type: 'object', properties: {} },
105
+ },
106
+ }));
107
+ if (options.tool_choice) payload.tool_choice = options.tool_choice;
108
+ }
109
+
110
+ /** @private */
111
+ _normalizeToolCallsOnResponse(res) {
112
+ if (!res || !res.choices?.[0]?.message?.tool_calls) return;
113
+ res.tool_calls = res.choices[0].message.tool_calls.map(tc => ({
114
+ id: tc.id || null,
115
+ name: (tc.function || {}).name || null,
116
+ arguments: (() => { try { return JSON.parse((tc.function || {}).arguments || '{}'); } catch { return {}; } })(),
117
+ }));
118
+ }
119
+
120
+ // ─── Streaming ───
121
+ async *stream(messages, options = {}) {
122
+ const payload = { model: options.model || 'gpt-like', messages, stream: true };
123
+ const res = await this._post(this._endpoint('chat'), payload, true);
124
+ yield* this._readSse(res.body);
125
+ }
126
+
127
+ async *streamEvents(messages, options = {}) {
128
+ const payload = { model: options.model || 'gpt-like', messages, stream: true };
129
+ const res = await this._post(this._endpoint('chat'), payload, true);
130
+ for await (const delta of this._readSse(res.body)) {
131
+ yield { type: 'delta', data: delta };
132
+ }
133
+ yield { type: 'end', data: null };
134
+ }
135
+
136
+ /** @private */
137
+ async *_readSse(body) {
138
+ const reader = body.getReader();
139
+ const decoder = new TextDecoder();
140
+ let buffer = '';
141
+ try {
142
+ while (true) {
143
+ const { done, value } = await reader.read();
144
+ if (done) break;
145
+ buffer += decoder.decode(value, { stream: true });
146
+ const lines = buffer.split(/\r?\n/);
147
+ buffer = lines.pop() || '';
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (trimmed === '' || trimmed.startsWith(':') || !trimmed.startsWith('data:')) continue;
151
+ const json = trimmed.slice(5).trim();
152
+ if (json === '[DONE]') return;
153
+ try {
154
+ const decoded = JSON.parse(json);
155
+ const delta = decoded?.choices?.[0]?.delta?.content || null;
156
+ if (delta !== null) yield delta;
157
+ } catch { /* skip */ }
158
+ }
159
+ }
160
+ } finally {
161
+ reader.releaseLock();
162
+ }
163
+ }
164
+
165
+ supportsStreaming() { return true; }
166
+
167
+ // ─── Models ───
168
+ async listModels() {
169
+ const url = this.baseUrl + this._modelsPath();
170
+ return (await this._get(url)) || {};
171
+ }
172
+
173
+ async getModel(id) {
174
+ const url = this.baseUrl + this._modelsPath() + '/' + encodeURIComponent(id);
175
+ return (await this._get(url)) || {};
176
+ }
177
+
178
+ /** @private */
179
+ _modelsPath() {
180
+ if (this.paths.models) return this.paths.models;
181
+ if (/\/v\d+(?:$|\/)/.test(this.baseUrl)) return '/models';
182
+ return '/v1/models';
183
+ }
184
+
185
+ // ─── Embeddings ───
186
+ async embeddings(inputs, options = {}) {
187
+ const payload = { model: options.model || 'embedding-model', input: inputs };
188
+ const res = await this._post(this._endpoint('embeddings'), payload);
189
+ return {
190
+ embeddings: (res.data || []).map(d => d.embedding || []),
191
+ usage: res.usage || {},
192
+ raw: res,
193
+ };
194
+ }
195
+
196
+ // ─── Images ───
197
+ async generateImage(prompt, options = {}) {
198
+ const payload = { prompt, model: options.model || 'image-model', n: 1 };
199
+ const res = await this._post(this._endpoint('image'), payload);
200
+ return { images: res.data || [], raw: res };
201
+ }
202
+
203
+ // ─── Audio ───
204
+ async textToSpeech(text, options = {}) {
205
+ const payload = {
206
+ model: options.model || 'tts-model',
207
+ input: text,
208
+ voice: options.voice || 'alloy',
209
+ format: options.format || 'mp3',
210
+ };
211
+ const res = await fetch(this._endpoint('tts'), {
212
+ method: 'POST',
213
+ headers: this._headers(),
214
+ body: JSON.stringify(payload),
215
+ });
216
+ const arrayBuf = await res.arrayBuffer();
217
+ return { audio: Buffer.from(arrayBuf).toString('base64'), mime: 'audio/mpeg' };
218
+ }
219
+
220
+ async speechToText(filePath, options = {}) {
221
+ const fs = require('fs');
222
+ const path = require('path');
223
+ const formData = new FormData();
224
+ const fileBuffer = fs.readFileSync(filePath);
225
+ const blob = new Blob([fileBuffer], { type: 'application/octet-stream' });
226
+ formData.append('file', blob, path.basename(filePath));
227
+ formData.append('model', options.model || 'stt-model');
228
+ formData.append('response_format', 'json');
229
+
230
+ const headers = { [this.authHeader]: this.authPrefix + this.apiKey };
231
+ Object.assign(headers, this.extraHeaders);
232
+ const res = await fetch(this._endpoint('stt'), { method: 'POST', headers, body: formData });
233
+ const data = await res.json();
234
+ return { text: data.text || '', raw: data };
235
+ }
236
+ }
237
+
238
+ module.exports = CustomOpenAIProvider;
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GeminiProvider
5
+ * Google Generative Language API. Supports chat, simulated streaming, and embeddings.
6
+ */
7
+ class GeminiProvider {
8
+ /**
9
+ * @param {string} apiKey
10
+ * @param {string} [chatEndpoint='https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent']
11
+ */
12
+ constructor(apiKey, chatEndpoint = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent') {
13
+ this.apiKey = apiKey;
14
+ this.chatEndpoint = chatEndpoint;
15
+ this.embedEndpoint = 'https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent';
16
+ }
17
+
18
+ /** @private */
19
+ _keyQuery() { return `?key=${this.apiKey}`; }
20
+
21
+ async chat(messages, options = {}) {
22
+ const userTexts = messages
23
+ .filter(m => (m.role || '') !== 'system')
24
+ .map(m => m.content);
25
+
26
+ const payload = {
27
+ contents: [{ parts: [{ text: userTexts.join('\n') }] }],
28
+ };
29
+
30
+ const res = await fetch(this.chatEndpoint + this._keyQuery(), {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify(payload),
34
+ });
35
+ const data = await res.json();
36
+ return data || {};
37
+ }
38
+
39
+ async *stream(messages, options = {}) {
40
+ const full = await this.chat(messages, options);
41
+ const text = full?.candidates?.[0]?.content?.parts?.[0]?.text || '';
42
+ for (let i = 0; i < text.length; i += 80) {
43
+ yield text.slice(i, i + 80);
44
+ }
45
+ }
46
+
47
+ supportsStreaming() { return true; } // simulated
48
+
49
+ async embeddings(inputs, options = {}) {
50
+ const vectors = [];
51
+ for (const input of inputs) {
52
+ const payload = {
53
+ model: 'text-embedding-004',
54
+ content: { parts: [{ text: input }] },
55
+ };
56
+ const res = await fetch(this.embedEndpoint + this._keyQuery(), {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify(payload),
60
+ });
61
+ const data = await res.json();
62
+ vectors.push((data?.embedding || {}).values || []);
63
+ }
64
+ return { embeddings: vectors };
65
+ }
66
+ }
67
+
68
+ module.exports = GeminiProvider;