@fgv/ts-extras 5.0.2-0 → 5.1.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 (93) hide show
  1. package/CHANGELOG.json +15 -0
  2. package/dist/index.browser.js +6 -2
  3. package/dist/index.js +5 -1
  4. package/dist/packlets/ai-assist/apiClient.js +484 -0
  5. package/dist/packlets/ai-assist/converters.js +121 -0
  6. package/dist/packlets/ai-assist/index.js +10 -0
  7. package/dist/packlets/ai-assist/model.js +90 -0
  8. package/dist/packlets/ai-assist/registry.js +145 -0
  9. package/dist/packlets/ai-assist/toolFormats.js +160 -0
  10. package/dist/packlets/crypto-utils/constants.js +48 -0
  11. package/dist/packlets/crypto-utils/converters.js +155 -0
  12. package/dist/packlets/crypto-utils/directEncryptionProvider.js +86 -0
  13. package/dist/packlets/crypto-utils/encryptedFile.js +161 -0
  14. package/dist/packlets/crypto-utils/index.browser.js +41 -0
  15. package/dist/packlets/crypto-utils/index.js +41 -0
  16. package/dist/packlets/crypto-utils/keystore/converters.js +84 -0
  17. package/dist/packlets/crypto-utils/keystore/index.js +31 -0
  18. package/dist/packlets/crypto-utils/keystore/keyStore.js +758 -0
  19. package/dist/packlets/crypto-utils/keystore/model.js +64 -0
  20. package/dist/packlets/crypto-utils/model.js +39 -0
  21. package/dist/packlets/crypto-utils/nodeCryptoProvider.js +159 -0
  22. package/dist/packlets/experimental/formatter.js +1 -1
  23. package/dist/packlets/mustache/index.js +23 -0
  24. package/dist/packlets/mustache/interfaces.js +25 -0
  25. package/dist/packlets/mustache/mustacheTemplate.js +242 -0
  26. package/dist/packlets/record-jar/recordJarHelpers.js +1 -1
  27. package/dist/packlets/yaml/converters.js +46 -0
  28. package/dist/packlets/yaml/index.js +23 -0
  29. package/dist/packlets/zip-file-tree/index.js +1 -0
  30. package/dist/packlets/zip-file-tree/zipFileTreeAccessors.js +43 -2
  31. package/dist/packlets/zip-file-tree/zipFileTreeWriter.js +40 -0
  32. package/dist/ts-extras.d.ts +1990 -112
  33. package/dist/tsdoc-metadata.json +1 -1
  34. package/lib/index.browser.d.ts +3 -1
  35. package/lib/index.browser.js +6 -1
  36. package/lib/index.d.ts +5 -1
  37. package/lib/index.js +9 -1
  38. package/lib/packlets/ai-assist/apiClient.d.ts +60 -0
  39. package/lib/packlets/ai-assist/apiClient.js +488 -0
  40. package/lib/packlets/ai-assist/converters.d.ts +55 -0
  41. package/lib/packlets/ai-assist/converters.js +124 -0
  42. package/lib/packlets/ai-assist/index.d.ts +10 -0
  43. package/lib/packlets/ai-assist/index.js +33 -0
  44. package/lib/packlets/ai-assist/model.d.ts +222 -0
  45. package/lib/packlets/ai-assist/model.js +95 -0
  46. package/lib/packlets/ai-assist/registry.d.ts +25 -0
  47. package/lib/packlets/ai-assist/registry.js +150 -0
  48. package/lib/packlets/ai-assist/toolFormats.d.ts +44 -0
  49. package/lib/packlets/ai-assist/toolFormats.js +166 -0
  50. package/lib/packlets/crypto-utils/constants.d.ts +26 -0
  51. package/lib/packlets/crypto-utils/constants.js +51 -0
  52. package/lib/packlets/crypto-utils/converters.d.ts +58 -0
  53. package/lib/packlets/crypto-utils/converters.js +192 -0
  54. package/lib/packlets/crypto-utils/directEncryptionProvider.d.ts +69 -0
  55. package/lib/packlets/crypto-utils/directEncryptionProvider.js +90 -0
  56. package/lib/packlets/crypto-utils/encryptedFile.d.ts +88 -0
  57. package/lib/packlets/crypto-utils/encryptedFile.js +201 -0
  58. package/lib/packlets/crypto-utils/index.browser.d.ts +14 -0
  59. package/lib/packlets/crypto-utils/index.browser.js +91 -0
  60. package/lib/packlets/crypto-utils/index.d.ts +15 -0
  61. package/lib/packlets/crypto-utils/index.js +88 -0
  62. package/lib/packlets/crypto-utils/keystore/converters.d.ts +29 -0
  63. package/lib/packlets/crypto-utils/keystore/converters.js +87 -0
  64. package/lib/packlets/crypto-utils/keystore/index.d.ts +9 -0
  65. package/lib/packlets/crypto-utils/keystore/index.js +71 -0
  66. package/lib/packlets/crypto-utils/keystore/keyStore.d.ts +239 -0
  67. package/lib/packlets/crypto-utils/keystore/keyStore.js +795 -0
  68. package/lib/packlets/crypto-utils/keystore/model.d.ts +245 -0
  69. package/lib/packlets/crypto-utils/keystore/model.js +68 -0
  70. package/lib/packlets/crypto-utils/model.d.ts +236 -0
  71. package/lib/packlets/crypto-utils/model.js +76 -0
  72. package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +62 -0
  73. package/lib/packlets/crypto-utils/nodeCryptoProvider.js +196 -0
  74. package/lib/packlets/experimental/formatter.d.ts +1 -1
  75. package/lib/packlets/experimental/formatter.js +1 -1
  76. package/lib/packlets/mustache/index.d.ts +3 -0
  77. package/lib/packlets/mustache/index.js +27 -0
  78. package/lib/packlets/mustache/interfaces.d.ts +97 -0
  79. package/lib/packlets/mustache/interfaces.js +26 -0
  80. package/lib/packlets/mustache/mustacheTemplate.d.ts +76 -0
  81. package/lib/packlets/mustache/mustacheTemplate.js +249 -0
  82. package/lib/packlets/record-jar/recordJarHelpers.js +1 -1
  83. package/lib/packlets/yaml/converters.d.ts +9 -0
  84. package/lib/packlets/yaml/converters.js +82 -0
  85. package/lib/packlets/yaml/index.d.ts +2 -0
  86. package/lib/packlets/yaml/index.js +39 -0
  87. package/lib/packlets/zip-file-tree/index.d.ts +1 -0
  88. package/lib/packlets/zip-file-tree/index.js +15 -0
  89. package/lib/packlets/zip-file-tree/zipFileTreeAccessors.d.ts +31 -2
  90. package/lib/packlets/zip-file-tree/zipFileTreeAccessors.js +42 -1
  91. package/lib/packlets/zip-file-tree/zipFileTreeWriter.d.ts +27 -0
  92. package/lib/packlets/zip-file-tree/zipFileTreeWriter.js +43 -0
  93. package/package.json +37 -18
package/CHANGELOG.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@fgv/ts-extras",
3
3
  "entries": [
4
+ {
5
+ "version": "5.0.2",
6
+ "tag": "@fgv/ts-extras_v5.0.2",
7
+ "date": "Wed, 17 Dec 2025 18:08:09 GMT",
8
+ "comments": {
9
+ "none": [
10
+ {
11
+ "comment": "dual-publish"
12
+ },
13
+ {
14
+ "comment": "minor cleanup"
15
+ }
16
+ ]
17
+ }
18
+ },
4
19
  {
5
20
  "version": "5.0.1",
6
21
  "tag": "@fgv/ts-extras_v5.0.1",
@@ -21,15 +21,19 @@
21
21
  */
22
22
  /* c8 ignore start - Browser-specific export used conditionally in package.json */
23
23
  // eslint-disable-next-line @rushstack/packlets/mechanics
24
+ import * as Crypto from './packlets/crypto-utils/index.browser';
25
+ // eslint-disable-next-line @rushstack/packlets/mechanics
24
26
  import * as Csv from './packlets/csv/index.browser';
25
27
  import * as Experimental from './packlets/experimental';
26
28
  // eslint-disable-next-line @rushstack/packlets/mechanics
27
29
  import * as Hash from './packlets/hash/index.browser';
30
+ import * as Mustache from './packlets/mustache';
28
31
  // eslint-disable-next-line @rushstack/packlets/mechanics
29
32
  import * as RecordJar from './packlets/record-jar/index.browser';
30
33
  import * as ZipFileTree from './packlets/zip-file-tree';
31
34
  import { Converters } from './packlets/conversion';
32
- // Browser-safe exports - Node.js crypto-based hash excluded (using CRC32 instead)
33
- export { Converters, Csv, Experimental, Hash, RecordJar, ZipFileTree };
35
+ // Browser-safe exports - Node.js crypto-based providers excluded
36
+ // Use BrowserCryptoProvider from @fgv/ts-web-extras for browser crypto
37
+ export { Converters, Crypto, Csv, Experimental, Hash, Mustache, RecordJar, ZipFileTree };
34
38
  /* c8 ignore stop */
35
39
  //# sourceMappingURL=index.browser.js.map
package/dist/index.js CHANGED
@@ -19,11 +19,15 @@
19
19
  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
20
  * SOFTWARE.
21
21
  */
22
+ import * as AiAssist from './packlets/ai-assist';
23
+ import * as CryptoUtils from './packlets/crypto-utils';
22
24
  import * as Csv from './packlets/csv';
23
25
  import * as Experimental from './packlets/experimental';
24
26
  import * as Hash from './packlets/hash';
27
+ import * as Mustache from './packlets/mustache';
25
28
  import * as RecordJar from './packlets/record-jar';
29
+ import * as Yaml from './packlets/yaml';
26
30
  import * as ZipFileTree from './packlets/zip-file-tree';
27
31
  import { Converters } from './packlets/conversion';
28
- export { Converters, Csv, Experimental, Hash, RecordJar, ZipFileTree };
32
+ export { AiAssist, Converters, CryptoUtils, Csv, Experimental, Hash, Mustache, RecordJar, Yaml, ZipFileTree };
29
33
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,484 @@
1
+ // Copyright (c) 2026 Erik Fortune
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+ /**
21
+ * Chat completion client for AI assist with support for multiple provider APIs.
22
+ *
23
+ * Supports OpenAI-compatible providers (xAI, OpenAI, Groq, Mistral) directly,
24
+ * plus adapters for Anthropic and Google Gemini.
25
+ *
26
+ * When server-side tools (e.g. web_search) are configured, providers that support
27
+ * them will include tool configuration in the request and handle tool-augmented
28
+ * responses.
29
+ *
30
+ * @packageDocumentation
31
+ */
32
+ import { isJsonObject } from '@fgv/ts-json-base';
33
+ import { fail, succeed, Validators } from '@fgv/ts-utils';
34
+ import { resolveModel } from './model';
35
+ import { toAnthropicTools, toGeminiTools, toResponsesApiTools } from './toolFormats';
36
+ // ============================================================================
37
+ // Shared helpers
38
+ // ============================================================================
39
+ /**
40
+ * Builds the messages array from prompt + optional correction messages.
41
+ * @internal
42
+ */
43
+ function buildMessages(prompt, additionalMessages) {
44
+ const messages = [
45
+ { role: 'system', content: prompt.system },
46
+ { role: 'user', content: prompt.user }
47
+ ];
48
+ if (additionalMessages) {
49
+ for (const msg of additionalMessages) {
50
+ messages.push({ role: msg.role, content: msg.content });
51
+ }
52
+ }
53
+ return messages;
54
+ }
55
+ /**
56
+ * Makes an HTTP request and returns the parsed JSON, or a failure.
57
+ * @internal
58
+ */
59
+ async function fetchJson(url, headers, body, logger) {
60
+ /* c8 ignore next 1 - optional logger */
61
+ logger === null || logger === void 0 ? void 0 : logger.detail(`AI API request: POST ${url}`);
62
+ let response;
63
+ try {
64
+ response = await fetch(url, {
65
+ method: 'POST',
66
+ headers: Object.assign({ 'Content-Type': 'application/json' }, headers),
67
+ body: JSON.stringify(body)
68
+ });
69
+ }
70
+ catch (err) {
71
+ const detail = err instanceof Error ? err.message : String(err);
72
+ /* c8 ignore next 1 - optional logger */
73
+ logger === null || logger === void 0 ? void 0 : logger.error(`AI API request failed: ${detail}`);
74
+ return fail(`AI API request failed: ${detail}`);
75
+ }
76
+ if (!response.ok) {
77
+ const errorText = await response.text().catch(() => 'unknown error');
78
+ /* c8 ignore next 1 - optional logger */
79
+ logger === null || logger === void 0 ? void 0 : logger.error(`AI API returned ${response.status}: ${errorText}`);
80
+ return fail(`AI API returned ${response.status}: ${errorText}`);
81
+ }
82
+ /* c8 ignore next 1 - optional logger */
83
+ logger === null || logger === void 0 ? void 0 : logger.detail(`AI API response: ${response.status}`);
84
+ let json;
85
+ try {
86
+ json = await response.json();
87
+ }
88
+ catch (_a) {
89
+ /* c8 ignore next 1 - optional logger */
90
+ logger === null || logger === void 0 ? void 0 : logger.error('AI API returned invalid JSON response');
91
+ return fail('AI API returned invalid JSON response');
92
+ }
93
+ if (!isJsonObject(json)) {
94
+ /* c8 ignore next 1 - optional logger */
95
+ logger === null || logger === void 0 ? void 0 : logger.error('AI API returned non-object JSON response');
96
+ return fail('AI API returned non-object JSON response');
97
+ }
98
+ return succeed(json);
99
+ }
100
+ const openAiMessage = Validators.object({
101
+ content: Validators.string
102
+ });
103
+ const openAiChoice = Validators.object({
104
+ message: openAiMessage,
105
+ finish_reason: Validators.string
106
+ });
107
+ const openAiResponse = Validators.object({
108
+ choices: Validators.arrayOf(openAiChoice).withConstraint((arr) => arr.length > 0)
109
+ });
110
+ const responsesApiOutputText = Validators.object({
111
+ type: Validators.literal('output_text'),
112
+ text: Validators.string
113
+ });
114
+ const responsesApiMessage = Validators.object({
115
+ type: Validators.literal('message'),
116
+ role: Validators.string,
117
+ content: Validators.arrayOf(responsesApiOutputText).withConstraint((arr) => arr.length > 0)
118
+ });
119
+ const responsesApiOutputItem = Validators.isA('object', (v) => typeof v === 'object' && v !== null);
120
+ const responsesApiResponse = Validators.object({
121
+ output: Validators.arrayOf(responsesApiOutputItem).withConstraint((arr) => arr.length > 0),
122
+ status: Validators.string
123
+ });
124
+ const anthropicContentBlock = Validators.object({
125
+ text: Validators.string
126
+ });
127
+ const anthropicResponse = Validators.object({
128
+ content: Validators.arrayOf(anthropicContentBlock).withConstraint((arr) => arr.length > 0),
129
+ stop_reason: Validators.string
130
+ });
131
+ const geminiPart = Validators.object({
132
+ text: Validators.string
133
+ });
134
+ const geminiContent = Validators.object({
135
+ parts: Validators.arrayOf(geminiPart).withConstraint((arr) => arr.length > 0)
136
+ });
137
+ const geminiCandidate = Validators.object({
138
+ content: geminiContent,
139
+ finishReason: Validators.string
140
+ });
141
+ const geminiResponse = Validators.object({
142
+ candidates: Validators.arrayOf(geminiCandidate).withConstraint((arr) => arr.length > 0)
143
+ });
144
+ // ============================================================================
145
+ // OpenAI-compatible client (Chat Completions — no tools)
146
+ // ============================================================================
147
+ /**
148
+ * Calls an OpenAI-compatible chat completion endpoint.
149
+ * Works for xAI Grok, OpenAI, Groq, and Mistral.
150
+ * @internal
151
+ */
152
+ async function callOpenAiCompletion(config, prompt, additionalMessages, temperature = 0.7, logger) {
153
+ const url = `${config.baseUrl}/chat/completions`;
154
+ const messages = buildMessages(prompt, additionalMessages);
155
+ const body = { model: config.model, messages, temperature };
156
+ const headers = {
157
+ Authorization: `Bearer ${config.apiKey}`
158
+ };
159
+ /* c8 ignore next 1 - optional logger */
160
+ logger === null || logger === void 0 ? void 0 : logger.info(`OpenAI completion: model=${config.model}`);
161
+ const jsonResult = await fetchJson(url, headers, body, logger);
162
+ if (jsonResult.isFailure()) {
163
+ return fail(jsonResult.message);
164
+ }
165
+ return openAiResponse
166
+ .validate(jsonResult.value)
167
+ .withErrorFormat((msg) => `OpenAI API response: ${msg}`)
168
+ .onSuccess((response) => {
169
+ const choice = response.choices[0];
170
+ return succeed({
171
+ content: choice.message.content,
172
+ truncated: choice.finish_reason === 'length'
173
+ });
174
+ });
175
+ }
176
+ // ============================================================================
177
+ // OpenAI/xAI Responses API (with tools)
178
+ // ============================================================================
179
+ /**
180
+ * Extracts text content from a Responses API output array.
181
+ * Finds the first message-type output item and concatenates its text content blocks.
182
+ * @internal
183
+ */
184
+ function extractResponsesApiText(output) {
185
+ for (const item of output) {
186
+ if (item.type === 'message') {
187
+ const messageResult = responsesApiMessage.validate(item);
188
+ if (messageResult.isSuccess()) {
189
+ return succeed(messageResult.value.content.map((c) => c.text).join(''));
190
+ }
191
+ }
192
+ }
193
+ return fail('Responses API output contained no message with text content');
194
+ }
195
+ /**
196
+ * Calls the xAI/OpenAI Responses API with server-side tools.
197
+ * Used when tools are configured for an openai-format provider.
198
+ * @internal
199
+ */
200
+ async function callOpenAiResponsesCompletion(config, prompt, tools, additionalMessages, temperature = 0.7, logger) {
201
+ const url = `${config.baseUrl}/responses`;
202
+ const input = buildMessages(prompt, additionalMessages);
203
+ const body = {
204
+ model: config.model,
205
+ input,
206
+ tools: toResponsesApiTools(tools),
207
+ temperature
208
+ };
209
+ const headers = {
210
+ Authorization: `Bearer ${config.apiKey}`
211
+ };
212
+ /* c8 ignore next 1 - optional logger */
213
+ logger === null || logger === void 0 ? void 0 : logger.info(`OpenAI Responses API: model=${config.model}, tools=${tools.map((t) => t.type).join(',')}`);
214
+ const jsonResult = await fetchJson(url, headers, body, logger);
215
+ if (jsonResult.isFailure()) {
216
+ return fail(jsonResult.message);
217
+ }
218
+ return responsesApiResponse
219
+ .validate(jsonResult.value)
220
+ .withErrorFormat((msg) => `Responses API response: ${msg}`)
221
+ .onSuccess((response) => {
222
+ return extractResponsesApiText(response.output).onSuccess((text) => succeed({
223
+ content: text,
224
+ truncated: response.status === 'incomplete'
225
+ }));
226
+ });
227
+ }
228
+ // ============================================================================
229
+ // Anthropic adapter
230
+ // ============================================================================
231
+ /**
232
+ * Extracts text content from Anthropic response content blocks.
233
+ * When tools are used, the content array contains mixed block types
234
+ * (text, server_tool_use, web_search_tool_result). We extract and
235
+ * concatenate only the text blocks.
236
+ * @internal
237
+ */
238
+ function extractAnthropicText(content) {
239
+ const textParts = [];
240
+ for (const block of content) {
241
+ if (typeof block === 'object' && block !== null && 'type' in block) {
242
+ const typed = block;
243
+ if (typed.type === 'text' && typeof typed.text === 'string') {
244
+ textParts.push(typed.text);
245
+ }
246
+ }
247
+ }
248
+ if (textParts.length === 0) {
249
+ return fail('Anthropic response contained no text content blocks');
250
+ }
251
+ return succeed(textParts.join(''));
252
+ }
253
+ /**
254
+ * Calls the Anthropic Messages API.
255
+ * When tools are configured, includes them in the request and handles
256
+ * mixed content block responses.
257
+ * @internal
258
+ */
259
+ async function callAnthropicCompletion(config, prompt, additionalMessages, temperature = 0.7, logger, tools) {
260
+ const url = `${config.baseUrl}/messages`;
261
+ // Anthropic uses system as a top-level field, not in messages
262
+ const messages = [{ role: 'user', content: prompt.user }];
263
+ if (additionalMessages) {
264
+ for (const msg of additionalMessages) {
265
+ // Anthropic doesn't have a system role in messages
266
+ if (msg.role !== 'system') {
267
+ messages.push({ role: msg.role, content: msg.content });
268
+ }
269
+ }
270
+ }
271
+ const body = {
272
+ model: config.model,
273
+ system: prompt.system,
274
+ messages,
275
+ max_tokens: 4096,
276
+ temperature
277
+ };
278
+ if (tools && tools.length > 0) {
279
+ body.tools = toAnthropicTools(tools);
280
+ /* c8 ignore next 3 - optional logger diagnostic output */
281
+ logger === null || logger === void 0 ? void 0 : logger.info(`Anthropic completion: model=${config.model}, tools=${tools.map((t) => t.type).join(',')}`);
282
+ }
283
+ else {
284
+ /* c8 ignore next 1 - optional logger */
285
+ logger === null || logger === void 0 ? void 0 : logger.info(`Anthropic completion: model=${config.model}`);
286
+ }
287
+ const headers = {
288
+ 'x-api-key': config.apiKey,
289
+ 'anthropic-version': '2023-06-01',
290
+ 'anthropic-dangerous-direct-browser-access': 'true'
291
+ };
292
+ const jsonResult = await fetchJson(url, headers, body, logger);
293
+ if (jsonResult.isFailure()) {
294
+ return fail(jsonResult.message);
295
+ }
296
+ // When tools are used, the response content is a mixed array of block types.
297
+ // We need to extract text from all text blocks.
298
+ if (tools && tools.length > 0) {
299
+ const rawContent = jsonResult.value.content;
300
+ const stopReason = jsonResult.value.stop_reason;
301
+ if (!Array.isArray(rawContent)) {
302
+ return fail('Anthropic API response: content is not an array');
303
+ }
304
+ return extractAnthropicText(rawContent).onSuccess((text) => succeed({
305
+ content: text,
306
+ truncated: stopReason === 'max_tokens'
307
+ }));
308
+ }
309
+ return anthropicResponse
310
+ .validate(jsonResult.value)
311
+ .withErrorFormat((msg) => `Anthropic API response: ${msg}`)
312
+ .onSuccess((response) => {
313
+ return succeed({
314
+ content: response.content[0].text,
315
+ truncated: response.stop_reason === 'max_tokens'
316
+ });
317
+ });
318
+ }
319
+ // ============================================================================
320
+ // Google Gemini adapter
321
+ // ============================================================================
322
+ /**
323
+ * Calls the Google Gemini generateContent API.
324
+ * When tools are configured, includes Google Search grounding.
325
+ * @internal
326
+ */
327
+ async function callGeminiCompletion(config, prompt, additionalMessages, temperature = 0.7, logger, tools) {
328
+ const url = `${config.baseUrl}/models/${config.model}:generateContent`;
329
+ // Gemini uses 'contents' with 'parts', and 'model' role instead of 'assistant'
330
+ const contents = [
331
+ { role: 'user', parts: [{ text: prompt.user }] }
332
+ ];
333
+ if (additionalMessages) {
334
+ for (const msg of additionalMessages) {
335
+ if (msg.role !== 'system') {
336
+ contents.push({
337
+ role: msg.role === 'assistant' ? 'model' : msg.role,
338
+ parts: [{ text: msg.content }]
339
+ });
340
+ }
341
+ }
342
+ }
343
+ const body = {
344
+ systemInstruction: { parts: [{ text: prompt.system }] },
345
+ contents,
346
+ generationConfig: { temperature }
347
+ };
348
+ if (tools && tools.length > 0) {
349
+ body.tools = toGeminiTools(tools);
350
+ /* c8 ignore next 1 - optional logger */
351
+ logger === null || logger === void 0 ? void 0 : logger.info(`Gemini completion: model=${config.model}, tools=${tools.map((t) => t.type).join(',')}`);
352
+ }
353
+ else {
354
+ /* c8 ignore next 1 - optional logger */
355
+ logger === null || logger === void 0 ? void 0 : logger.info(`Gemini completion: model=${config.model}`);
356
+ }
357
+ const headers = {
358
+ 'x-goog-api-key': config.apiKey
359
+ };
360
+ const jsonResult = await fetchJson(url, headers, body, logger);
361
+ if (jsonResult.isFailure()) {
362
+ return fail(jsonResult.message);
363
+ }
364
+ return geminiResponse
365
+ .validate(jsonResult.value)
366
+ .withErrorFormat((msg) => `Gemini API response: ${msg}`)
367
+ .onSuccess((response) => {
368
+ const candidate = response.candidates[0];
369
+ return succeed({
370
+ content: candidate.content.parts[0].text,
371
+ truncated: candidate.finishReason === 'MAX_TOKENS'
372
+ });
373
+ });
374
+ }
375
+ // ============================================================================
376
+ // Provider dispatcher
377
+ // ============================================================================
378
+ /**
379
+ * Calls the appropriate chat completion API for a given provider.
380
+ *
381
+ * Routes based on the provider descriptor's `apiFormat` field:
382
+ * - `'openai'` for xAI, OpenAI, Groq, Mistral
383
+ * - `'anthropic'` for Anthropic Claude
384
+ * - `'gemini'` for Google Gemini
385
+ *
386
+ * When tools are provided and the provider supports them:
387
+ * - OpenAI-format providers switch to the Responses API
388
+ * - Anthropic includes tools in the Messages API request
389
+ * - Gemini includes Google Search grounding
390
+ *
391
+ * @param params - Request parameters including descriptor, API key, prompt, and optional tools
392
+ * @returns The completion response with content and truncation status, or a failure
393
+ * @public
394
+ */
395
+ export async function callProviderCompletion(params) {
396
+ const { descriptor, apiKey, prompt, additionalMessages, temperature = 0.7, modelOverride, logger, tools } = params;
397
+ if (!descriptor.baseUrl) {
398
+ return fail(`provider "${descriptor.id}" has no API endpoint configured`);
399
+ }
400
+ const hasTools = tools !== undefined && tools.length > 0;
401
+ const modelContext = hasTools ? 'tools' : undefined;
402
+ const config = {
403
+ baseUrl: descriptor.baseUrl,
404
+ apiKey,
405
+ model: resolveModel(modelOverride !== null && modelOverride !== void 0 ? modelOverride : descriptor.defaultModel, modelContext)
406
+ };
407
+ /* c8 ignore next 8 - optional logger diagnostic output */
408
+ if (logger) {
409
+ const toolTypes = hasTools ? tools.map((t) => t.type).join(',') : 'none';
410
+ const supported = descriptor.supportedTools.length > 0 ? descriptor.supportedTools.join(',') : 'none';
411
+ logger.info(`AI completion: provider=${descriptor.id}, format=${descriptor.apiFormat}, model=${config.model}, ` +
412
+ `tools=${toolTypes}, supported=${supported}`);
413
+ }
414
+ switch (descriptor.apiFormat) {
415
+ case 'openai':
416
+ if (hasTools) {
417
+ return callOpenAiResponsesCompletion(config, prompt, tools, additionalMessages, temperature, logger);
418
+ }
419
+ return callOpenAiCompletion(config, prompt, additionalMessages, temperature, logger);
420
+ case 'anthropic':
421
+ return callAnthropicCompletion(config, prompt, additionalMessages, temperature, logger, tools);
422
+ case 'gemini':
423
+ return callGeminiCompletion(config, prompt, additionalMessages, temperature, logger, tools);
424
+ /* c8 ignore next 4 - defensive coding: exhaustive switch guaranteed by TypeScript */
425
+ default: {
426
+ const _exhaustive = descriptor.apiFormat;
427
+ return fail(`unsupported API format: ${String(_exhaustive)}`);
428
+ }
429
+ }
430
+ }
431
+ // ============================================================================
432
+ // Proxied completion (routes through a backend server)
433
+ // ============================================================================
434
+ /**
435
+ * Calls the AI completion endpoint on a proxy server instead of calling
436
+ * the provider API directly from the browser.
437
+ *
438
+ * The proxy server handles provider dispatch, CORS, and API key forwarding.
439
+ * The request shape mirrors {@link IProviderCompletionParams} but is serialized
440
+ * as JSON for the proxy endpoint.
441
+ *
442
+ * @param proxyUrl - Base URL of the proxy server (e.g. `http://localhost:3001`)
443
+ * @param params - Same parameters as {@link callProviderCompletion}
444
+ * @returns The completion response, or a failure
445
+ * @public
446
+ */
447
+ export async function callProxiedCompletion(proxyUrl, params) {
448
+ const { descriptor, apiKey, prompt, additionalMessages, temperature, modelOverride, logger, tools } = params;
449
+ const body = {
450
+ providerId: descriptor.id,
451
+ apiKey,
452
+ prompt: { system: prompt.system, user: prompt.user },
453
+ temperature: temperature !== null && temperature !== void 0 ? temperature : 0.7
454
+ };
455
+ if (additionalMessages && additionalMessages.length > 0) {
456
+ body.additionalMessages = additionalMessages;
457
+ }
458
+ if (modelOverride !== undefined) {
459
+ body.modelOverride = modelOverride;
460
+ }
461
+ if (tools && tools.length > 0) {
462
+ body.tools = tools;
463
+ }
464
+ /* c8 ignore next 1 - optional logger */
465
+ logger === null || logger === void 0 ? void 0 : logger.info(`AI proxy request: provider=${descriptor.id}, proxy=${proxyUrl}`);
466
+ const url = `${proxyUrl}/api/ai/completion`;
467
+ const jsonResult = await fetchJson(url, {}, body, logger);
468
+ if (jsonResult.isFailure()) {
469
+ return fail(jsonResult.message);
470
+ }
471
+ // Check for error response from proxy
472
+ const response = jsonResult.value;
473
+ if (typeof response.error === 'string') {
474
+ return fail(`proxy: ${response.error}`);
475
+ }
476
+ if (typeof response.content !== 'string') {
477
+ return fail('proxy returned invalid response: missing content');
478
+ }
479
+ return succeed({
480
+ content: response.content,
481
+ truncated: response.truncated === true
482
+ });
483
+ }
484
+ //# sourceMappingURL=apiClient.js.map
@@ -0,0 +1,121 @@
1
+ // Copyright (c) 2026 Erik Fortune
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+ /**
21
+ * Converters for AI assist settings types.
22
+ * @packageDocumentation
23
+ */
24
+ import { Converters } from '@fgv/ts-utils';
25
+ import { allModelSpecKeys } from './model';
26
+ import { allProviderIds } from './registry';
27
+ // ============================================================================
28
+ // Provider ID
29
+ // ============================================================================
30
+ /**
31
+ * Converter for {@link AiProviderId}.
32
+ * @public
33
+ */
34
+ export const aiProviderId = Converters.enumeratedValue(allProviderIds);
35
+ // ============================================================================
36
+ // Server-Side Tool Converters
37
+ // ============================================================================
38
+ /**
39
+ * All known server-side tool type values.
40
+ * @internal
41
+ */
42
+ const allServerToolTypes = ['web_search'];
43
+ /**
44
+ * Converter for {@link AiServerToolType}.
45
+ * @public
46
+ */
47
+ export const aiServerToolType = Converters.enumeratedValue(allServerToolTypes);
48
+ /**
49
+ * Converter for {@link IAiWebSearchToolConfig}.
50
+ * @public
51
+ */
52
+ export const aiWebSearchToolConfig = Converters.strictObject({
53
+ type: Converters.enumeratedValue(['web_search']),
54
+ allowedDomains: Converters.arrayOf(Converters.string).optional(),
55
+ blockedDomains: Converters.arrayOf(Converters.string).optional(),
56
+ maxUses: Converters.number.optional(),
57
+ enableImageUnderstanding: Converters.boolean.optional()
58
+ });
59
+ /**
60
+ * Converter for {@link AiServerToolConfig} (discriminated union on `type`).
61
+ * @public
62
+ */
63
+ export const aiServerToolConfig = Converters.discriminatedObject('type', {
64
+ web_search: aiWebSearchToolConfig
65
+ });
66
+ /**
67
+ * Converter for {@link IAiToolEnablement}.
68
+ * @public
69
+ */
70
+ export const aiToolEnablement = Converters.strictObject({
71
+ type: aiServerToolType,
72
+ enabled: Converters.boolean,
73
+ config: aiServerToolConfig.optional()
74
+ });
75
+ // ============================================================================
76
+ // Model Specification
77
+ // ============================================================================
78
+ /**
79
+ * Converter for {@link ModelSpecKey}.
80
+ * @public
81
+ */
82
+ export const modelSpecKey = Converters.enumeratedValue(allModelSpecKeys);
83
+ /**
84
+ * Recursive converter for {@link ModelSpec}.
85
+ * Accepts a string or an object whose values are themselves ModelSpec values,
86
+ * with keys constrained to known {@link ModelSpecKey} values.
87
+ * Uses the `self` parameter from `Converters.generic` for recursion.
88
+ * @public
89
+ */
90
+ export const modelSpec = Converters.generic((from, self) => {
91
+ return Converters.oneOf([
92
+ Converters.string,
93
+ Converters.recordOf(self, { keyConverter: modelSpecKey })
94
+ ])
95
+ .withFormattedError(() => 'expected model spec (string or object with keys: base, tools, image)')
96
+ .convert(from);
97
+ });
98
+ // ============================================================================
99
+ // Provider Config & Settings
100
+ // ============================================================================
101
+ /**
102
+ * Converter for {@link IAiAssistProviderConfig}.
103
+ * @public
104
+ */
105
+ export const aiAssistProviderConfig = Converters.strictObject({
106
+ provider: aiProviderId,
107
+ secretName: Converters.string.optional(),
108
+ model: modelSpec.optional(),
109
+ tools: Converters.arrayOf(aiToolEnablement).optional()
110
+ });
111
+ /**
112
+ * Converter for {@link IAiAssistSettings}.
113
+ * @public
114
+ */
115
+ export const aiAssistSettings = Converters.strictObject({
116
+ providers: Converters.arrayOf(aiAssistProviderConfig),
117
+ defaultProvider: aiProviderId.optional(),
118
+ proxyUrl: Converters.string.optional(),
119
+ proxyAllProviders: Converters.boolean.optional()
120
+ });
121
+ //# sourceMappingURL=converters.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * AI assist packlet - provider registry, prompt class, settings, and API client.
3
+ * @packageDocumentation
4
+ */
5
+ export { AiPrompt, DEFAULT_AI_ASSIST, allModelSpecKeys, MODEL_SPEC_BASE_KEY, resolveModel } from './model';
6
+ export { allProviderIds, getProviderDescriptors, getProviderDescriptor } from './registry';
7
+ export { callProviderCompletion, callProxiedCompletion } from './apiClient';
8
+ export { aiProviderId, aiServerToolType, aiWebSearchToolConfig, aiServerToolConfig, aiToolEnablement, aiAssistProviderConfig, aiAssistSettings, modelSpecKey, modelSpec } from './converters';
9
+ export { resolveEffectiveTools } from './toolFormats';
10
+ //# sourceMappingURL=index.js.map