@contentgrowth/llm-service 1.2.4 → 1.3.1

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/README.md CHANGED
@@ -81,6 +81,238 @@ ConfigManager.setConfigProvider(new MyDatabaseConfigProvider());
81
81
  const service = new LLMService(env);
82
82
  ```
83
83
 
84
+ ### Google Gemini & Vertex AI Configuration
85
+
86
+ The service provides unified support for Google's AI platforms through the `@google/genai` SDK:
87
+
88
+ #### 1. Gemini AI Studio (Consumer API)
89
+ Uses your AI Studio API key directly.
90
+ ```env
91
+ LLM_PROVIDER=gemini
92
+ GEMINI_API_KEY=your-api-key-from-ai-studio
93
+ GEMINI_MODEL=gemini-3.5-flash
94
+ ```
95
+
96
+ #### 2. Vertex AI (Enterprise) — Two Authentication Modes
97
+
98
+ | Mode | Use Case | Required Config |
99
+ |------|----------|-----------------|
100
+ | **ADC / IAM (Default)** | Production, service accounts, Workload Identity | `VERTEX_PROJECT`, `VERTEX_LOCATION` |
101
+ | **Express Mode (API Key)** | Quick setup, dev/test, no service account | `VERTEX_API_KEY` (project auto-resolved) |
102
+
103
+ **Vertex Express Mode (API Key)** — Recommended for quick setup:
104
+ ```env
105
+ LLM_PROVIDER=vertex
106
+ VERTEX_API_KEY=AIza... # API key from Vertex AI Studio
107
+ # VERTEX_PROJECT and VERTEX_LOCATION are OPTIONAL — SDK resolves project from key
108
+ ```
109
+
110
+ **Vertex ADC/IAM Mode** — For production with service accounts:
111
+ ```env
112
+ LLM_PROVIDER=vertex
113
+ VERTEX_PROJECT=your-gcp-project-id
114
+ VERTEX_LOCATION=us-central1
115
+ # VERTEX_API_KEY is NOT needed (uses Application Default Credentials)
116
+ ```
117
+
118
+ **Legacy aliases** (also supported):
119
+ - `GOOGLE_CLOUD_PROJECT` → `VERTEX_PROJECT`
120
+ - `GOOGLE_CLOUD_LOCATION` → `VERTEX_LOCATION`
121
+
122
+ #### Model Overrides (All Providers)
123
+ ```env
124
+ VERTEX_MODEL=gemini-3.5-flash
125
+ VERTEX_MODEL_EDGE=gemini-3.5-pro
126
+ VERTEX_MODEL_FAST=gemini-3.5-flash
127
+ VERTEX_IMAGE_MODEL=gemini-3.5-pro-image-preview
128
+ VERTEX_VIDEO_MODEL=veo
129
+ ```
130
+
131
+ ---
132
+
133
+ ### Programmatic Configuration (No Environment Variables)
134
+
135
+ You can configure the provider directly via code by implementing a custom `ConfigProvider`. This is useful when you don't want to rely on environment variables or need dynamic configuration.
136
+
137
+ #### Vertex Express Mode (API Key) — Programmatic
138
+
139
+ ```javascript
140
+ import { LLMService, ConfigManager, BaseConfigProvider } from '@contentgrowth/llm-service';
141
+
142
+ class VertexExpressConfigProvider extends BaseConfigProvider {
143
+ constructor(apiKey) {
144
+ super();
145
+ this.apiKey = apiKey;
146
+ }
147
+
148
+ async getConfig(tenantId, env) {
149
+ return {
150
+ provider: 'vertex',
151
+ apiKey: this.apiKey,
152
+ // project & location are OPTIONAL — SDK resolves from API key
153
+ models: {
154
+ default: 'gemini-3.5-flash',
155
+ edge: 'gemini-3.5-pro',
156
+ fast: 'gemini-3.5-flash',
157
+ image: 'gemini-3.5-pro-image-preview',
158
+ video: 'veo',
159
+ },
160
+ temperature: 0.7,
161
+ maxTokens: 65536,
162
+ capabilities: { chat: true, image: true, video: true },
163
+ };
164
+ }
165
+ }
166
+
167
+ // Register once at startup
168
+ ConfigManager.setConfigProvider(new VertexExpressConfigProvider('AIza...'));
169
+
170
+ // Use normally
171
+ const service = new LLMService(env);
172
+ const response = await service.chat('Hello Vertex!', 'tenant-id');
173
+ ```
174
+
175
+ #### Vertex ADC/IAM Mode — Programmatic
176
+
177
+ ```javascript
178
+ class VertexADCConfigProvider extends BaseConfigProvider {
179
+ constructor(project, location = 'us-central1') {
180
+ super();
181
+ this.project = project;
182
+ this.location = location;
183
+ }
184
+
185
+ async getConfig(tenantId, env) {
186
+ return {
187
+ provider: 'vertex',
188
+ project: this.project,
189
+ location: this.location,
190
+ // apiKey NOT needed — uses Application Default Credentials
191
+ models: {
192
+ default: 'gemini-3.5-flash',
193
+ edge: 'gemini-3.5-pro',
194
+ fast: 'gemini-3.5-flash',
195
+ image: 'gemini-3.5-pro-image-preview',
196
+ video: 'veo',
197
+ },
198
+ temperature: 0.7,
199
+ maxTokens: 65536,
200
+ capabilities: { chat: true, image: true, video: true },
201
+ };
202
+ }
203
+ }
204
+
205
+ ConfigManager.setConfigProvider(new VertexADCConfigProvider('my-gcp-project', 'us-central1'));
206
+ ```
207
+
208
+ #### Gemini AI Studio — Programmatic
209
+
210
+ ```javascript
211
+ class GeminiStudioConfigProvider extends BaseConfigProvider {
212
+ constructor(apiKey) {
213
+ super();
214
+ this.apiKey = apiKey;
215
+ }
216
+
217
+ async getConfig(tenantId, env) {
218
+ return {
219
+ provider: 'gemini',
220
+ apiKey: this.apiKey,
221
+ models: {
222
+ default: 'gemini-3.5-flash',
223
+ edge: 'gemini-3.5-pro',
224
+ fast: 'gemini-3.5-flash',
225
+ image: 'gemini-3.5-pro-image-preview',
226
+ video: 'veo',
227
+ },
228
+ temperature: 0.7,
229
+ maxTokens: 65536,
230
+ capabilities: { chat: true, image: true, video: true },
231
+ };
232
+ }
233
+ }
234
+
235
+ ConfigManager.setConfigProvider(new GeminiStudioConfigProvider('your-gemini-api-key'));
236
+ ```
237
+
238
+ ---
239
+
240
+ ### Custom & OpenAI-Compatible Providers
241
+
242
+ Many third-party LLM proxies and gateways (LiteLLM, OpenRouter, custom corporate proxies) expose an OpenAI-compatible API. Because the OpenAI provider in this library accepts a `baseURL` override, you can point it at any of these services and use any model they expose — including Gemini, Claude, or your own fine-tuned models — without writing a custom provider.
243
+
244
+ #### How it works
245
+
246
+ Set `LLM_PROVIDER=openai`, point `OPENAI_BASE_URL` at your proxy, and set `OPENAI_MODEL` to whatever model name your proxy advertises. The library sends standard OpenAI-format requests; your proxy handles the translation.
247
+
248
+ #### Example: Gemini via an OpenAI-compatible proxy
249
+
250
+ ```env
251
+ LLM_PROVIDER=openai
252
+ OPENAI_API_KEY=your-proxy-api-key
253
+ OPENAI_BASE_URL=https://your-proxy.example.com/v1
254
+ OPENAI_MODEL=gemini-3.5-flash
255
+ ```
256
+
257
+ No code changes needed. All features — chat, tool calling, JSON mode — work through the proxy using the OpenAI wire format.
258
+
259
+ #### Example: Programmatic configuration
260
+
261
+ ```javascript
262
+ import { LLMService, ConfigManager, BaseConfigProvider } from '@contentgrowth/llm-service';
263
+
264
+ class ProxyConfigProvider extends BaseConfigProvider {
265
+ async getConfig(tenantId, env) {
266
+ return {
267
+ provider: 'openai',
268
+ apiKey: 'your-proxy-api-key',
269
+ baseURL: 'https://your-proxy.example.com/v1',
270
+ models: {
271
+ default: 'gemini-3.5-flash',
272
+ fast: 'gemini-3.5-flash',
273
+ edge: 'gemini-3.5-pro',
274
+ },
275
+ temperature: 0.7,
276
+ maxTokens: 65536,
277
+ };
278
+ }
279
+ }
280
+
281
+ ConfigManager.setConfigProvider(new ProxyConfigProvider());
282
+ const service = new LLMService(env);
283
+ ```
284
+
285
+ #### BYOK tenants via proxy
286
+
287
+ For multi-tenant setups, tenants can supply their own proxy endpoint alongside their API key:
288
+
289
+ ```json
290
+ {
291
+ "provider": "openai",
292
+ "api_key": "tenant-proxy-key",
293
+ "baseURL": "https://tenant-proxy.example.com/v1"
294
+ }
295
+ ```
296
+
297
+ #### baseURL support across all providers
298
+
299
+ Every provider respects a `baseURL` override, both from environment variables and from the tenant config object:
300
+
301
+ | Provider | Env var | Shared fallback |
302
+ |----------|---------|-----------------|
303
+ | `openai` | `OPENAI_BASE_URL` | `LLM_BASE_URL` |
304
+ | `gemini` | `GEMINI_BASE_URL` | `LLM_BASE_URL` |
305
+ | `anthropic` | `ANTHROPIC_BASE_URL` | `LLM_BASE_URL` |
306
+ | `vertex` | `VERTEX_BASE_URL` | `LLM_BASE_URL` |
307
+
308
+ `LLM_BASE_URL` applies to whichever provider is active, useful when routing all traffic through a single proxy regardless of provider.
309
+
310
+ #### Caveat: JSON schema mode
311
+
312
+ If your proxy doesn't natively support OpenAI's `response_format: { type: 'json_schema' }` for the model it's proxying, strict schema mode may fall back to `json_object` or return an error. Proxies like LiteLLM handle this by translating to the target model's native format, but not all do. Test with a `chatCompletionJson` call first to confirm schema mode works end-to-end.
313
+
314
+ ---
315
+
84
316
  ### JSON Mode & Structured Outputs
85
317
 
86
318
  The service supports native JSON mode for OpenAI and Gemini, guaranteeing valid JSON responses without escaping issues.
package/dist/index.cjs CHANGED
@@ -125,6 +125,7 @@ var DefaultConfigProvider = class extends BaseConfigProvider {
125
125
  models: MODEL_CONFIGS[tenantConfig.provider],
126
126
  // for backward compatibility, api_key is deprecated and shall never be used!!
127
127
  apiKey: tenantConfig.apiKey || tenantConfig.api_key,
128
+ baseURL: tenantConfig.baseURL || tenantConfig.base_url || void 0,
128
129
  project: tenantConfig.project,
129
130
  location: tenantConfig.location,
130
131
  temperature: parseFloat(env.DEFAULT_TEMPERATURE || "0.7"),
@@ -138,9 +139,11 @@ var DefaultConfigProvider = class extends BaseConfigProvider {
138
139
  const provider = ((_a = env.LLM_PROVIDER) == null ? void 0 : _a.toLowerCase()) || "openai";
139
140
  const providerDefaults = MODEL_CONFIGS[provider] || MODEL_CONFIGS["openai"];
140
141
  let apiKey;
142
+ let baseURL;
141
143
  let models = { ...providerDefaults };
142
144
  if (provider === "openai") {
143
145
  apiKey = env.OPENAI_API_KEY;
146
+ baseURL = env.OPENAI_BASE_URL || env.LLM_BASE_URL || void 0;
144
147
  models = {
145
148
  default: env.OPENAI_MODEL || providerDefaults.default,
146
149
  edge: env.OPENAI_MODEL_EDGE || providerDefaults.edge,
@@ -150,6 +153,7 @@ var DefaultConfigProvider = class extends BaseConfigProvider {
150
153
  };
151
154
  } else if (provider === "gemini") {
152
155
  apiKey = env.GEMINI_API_KEY;
156
+ baseURL = env.GEMINI_BASE_URL || env.LLM_BASE_URL || void 0;
153
157
  models = {
154
158
  default: env.GEMINI_MODEL || providerDefaults.default,
155
159
  edge: env.GEMINI_MODEL_EDGE || providerDefaults.edge,
@@ -159,8 +163,19 @@ var DefaultConfigProvider = class extends BaseConfigProvider {
159
163
  image: env.GEMINI_IMAGE_MODEL || providerDefaults.image,
160
164
  video: env.GEMINI_VIDEO_MODEL || providerDefaults.video
161
165
  };
166
+ } else if (provider === "anthropic") {
167
+ apiKey = env.ANTHROPIC_API_KEY;
168
+ baseURL = env.ANTHROPIC_BASE_URL || env.LLM_BASE_URL || void 0;
169
+ models = {
170
+ default: env.ANTHROPIC_MODEL || providerDefaults.default,
171
+ edge: env.ANTHROPIC_MODEL_EDGE || providerDefaults.edge,
172
+ fast: env.ANTHROPIC_MODEL_FAST || providerDefaults.fast,
173
+ cost: env.ANTHROPIC_MODEL_COST || providerDefaults.cost,
174
+ free: env.ANTHROPIC_MODEL_FREE || providerDefaults.free
175
+ };
162
176
  } else if (provider === "vertex") {
163
177
  apiKey = env.VERTEX_API_KEY;
178
+ baseURL = env.VERTEX_BASE_URL || env.LLM_BASE_URL || void 0;
164
179
  const project = env.VERTEX_PROJECT || env.GOOGLE_CLOUD_PROJECT;
165
180
  const location = env.VERTEX_LOCATION || env.GOOGLE_CLOUD_LOCATION || "us-central1";
166
181
  models = {
@@ -175,6 +190,7 @@ var DefaultConfigProvider = class extends BaseConfigProvider {
175
190
  return {
176
191
  provider,
177
192
  apiKey,
193
+ baseURL,
178
194
  project,
179
195
  location,
180
196
  models,
@@ -185,6 +201,7 @@ var DefaultConfigProvider = class extends BaseConfigProvider {
185
201
  return {
186
202
  provider,
187
203
  apiKey,
204
+ baseURL,
188
205
  models,
189
206
  temperature: parseFloat(env.DEFAULT_TEMPERATURE || "0.7"),
190
207
  maxTokens: parseInt(env.DEFAULT_MAX_TOKENS || "65536")
@@ -448,7 +465,11 @@ function extractTextAndJson(input) {
448
465
  var OpenAIProvider = class extends BaseLLMProvider {
449
466
  constructor(config) {
450
467
  super(config);
451
- this.client = new import_openai.default({ apiKey: config.apiKey });
468
+ const clientOptions = { apiKey: config.apiKey };
469
+ if (config.baseURL) {
470
+ clientOptions.baseURL = config.baseURL;
471
+ }
472
+ this.client = new import_openai.default(clientOptions);
452
473
  this.models = config.models;
453
474
  this.defaultModel = config.models.default;
454
475
  }
@@ -630,11 +651,13 @@ var GoogleProvider = class extends BaseLLMProvider {
630
651
  this.models = config.models;
631
652
  this.defaultModel = config.models.default;
632
653
  this._pendingOperations = /* @__PURE__ */ new Map();
654
+ const httpOptions = config.baseURL ? { baseUrl: config.baseURL } : void 0;
633
655
  if (config.provider === "vertex") {
634
656
  if (config.apiKey) {
635
657
  this.client = new import_genai.GoogleGenAI({
636
658
  vertexai: true,
637
- apiKey: config.apiKey
659
+ apiKey: config.apiKey,
660
+ ...httpOptions ? { httpOptions } : {}
638
661
  });
639
662
  } else {
640
663
  if (!config.project) {
@@ -643,12 +666,14 @@ var GoogleProvider = class extends BaseLLMProvider {
643
666
  this.client = new import_genai.GoogleGenAI({
644
667
  vertexai: true,
645
668
  project: config.project,
646
- location: config.location || "us-central1"
669
+ location: config.location || "us-central1",
670
+ ...httpOptions ? { httpOptions } : {}
647
671
  });
648
672
  }
649
673
  } else {
650
674
  this.client = new import_genai.GoogleGenAI({
651
- apiKey: config.apiKey
675
+ apiKey: config.apiKey,
676
+ ...httpOptions ? { httpOptions } : {}
652
677
  });
653
678
  }
654
679
  }
@@ -1197,14 +1222,66 @@ ${prompt}` : prompt;
1197
1222
  throw error;
1198
1223
  }
1199
1224
  }
1225
+ /**
1226
+ * Text-to-Speech synthesis using Gemini TTS models
1227
+ * @param {string} text - Text to synthesize
1228
+ * @param {Object} options - TTS options
1229
+ * @param {string} options.voice - Voice name (default: 'Aoede')
1230
+ * @param {string} options.model - Model to use (default: from config)
1231
+ * @returns {Promise<Buffer>} Audio data as Buffer
1232
+ */
1233
+ async textToSpeech(text, options = {}) {
1234
+ var _a, _b, _c, _d, _e;
1235
+ const voice = options.voice || "Aoede";
1236
+ const modelName = options.model || this.models.tts || "gemini-3.1-flash-tts-preview";
1237
+ const requestOptions = {
1238
+ model: modelName,
1239
+ contents: [{
1240
+ role: "user",
1241
+ parts: [{ text }]
1242
+ }],
1243
+ config: {
1244
+ responseModalities: ["AUDIO"],
1245
+ speechConfig: {
1246
+ voiceConfig: {
1247
+ prebuiltVoiceConfig: {
1248
+ voiceName: voice
1249
+ }
1250
+ }
1251
+ }
1252
+ }
1253
+ };
1254
+ try {
1255
+ const response = await this._generateContent(requestOptions);
1256
+ const candidate = (_a = response.candidates) == null ? void 0 : _a[0];
1257
+ if (!candidate) {
1258
+ throw new LLMServiceException("No candidates returned from TTS model", 500);
1259
+ }
1260
+ const audioPart = (_c = (_b = candidate.content) == null ? void 0 : _b.parts) == null ? void 0 : _c.find((p) => p.inlineData || p.inline_data);
1261
+ const audioB64 = ((_d = audioPart == null ? void 0 : audioPart.inlineData) == null ? void 0 : _d.data) || ((_e = audioPart == null ? void 0 : audioPart.inline_data) == null ? void 0 : _e.data);
1262
+ if (!audioB64) {
1263
+ throw new LLMServiceException("No audio data returned from TTS model", 500);
1264
+ }
1265
+ return Buffer.from(audioB64, "base64");
1266
+ } catch (error) {
1267
+ console.error(`[${this.constructor.name}] textToSpeech failed (API Key: ${this._getMaskedApiKey()}):`, error);
1268
+ throw error;
1269
+ }
1270
+ }
1200
1271
  };
1201
1272
 
1202
1273
  // src/llm/providers/anthropic-provider.js
1274
+ var import_sdk = __toESM(require("@anthropic-ai/sdk"), 1);
1203
1275
  var AnthropicProvider = class extends BaseLLMProvider {
1204
1276
  constructor(config) {
1205
1277
  super(config);
1206
1278
  this.models = config.models || {};
1207
1279
  this.defaultModel = this.models.default || "claude-3-5-sonnet-20241022";
1280
+ const clientOptions = { apiKey: config.apiKey || "" };
1281
+ if (config.baseURL) {
1282
+ clientOptions.baseURL = config.baseURL;
1283
+ }
1284
+ this.client = new import_sdk.default(clientOptions);
1208
1285
  }
1209
1286
  async chat(userMessage, systemPrompt = "", options = {}) {
1210
1287
  var _a;
@@ -1241,82 +1318,69 @@ var AnthropicProvider = class extends BaseLLMProvider {
1241
1318
  );
1242
1319
  }
1243
1320
  async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature, options = {}) {
1244
- const baseURL = this.config.baseURL || "https://api.anthropic.com";
1245
- const url = `${baseURL.replace(/\/$/, "")}/v1/messages`;
1246
- const formattedMessages = messages.map((m) => ({
1247
- role: m.role === "assistant" ? "assistant" : "user",
1248
- content: m.content
1249
- }));
1250
- const headers = {
1251
- "Content-Type": "application/json",
1252
- "x-api-key": this.config.apiKey || "",
1253
- "anthropic-version": "2023-06-01"
1254
- };
1255
- const payload = {
1256
- model: modelName,
1257
- messages: formattedMessages,
1258
- max_tokens: maxTokens,
1259
- temperature
1260
- };
1261
- if (systemPrompt) {
1262
- payload.system = systemPrompt;
1263
- }
1321
+ var _a, _b, _c, _d;
1322
+ let effectiveSystem = systemPrompt || void 0;
1264
1323
  if (options.responseFormat) {
1265
1324
  const formatType = typeof options.responseFormat === "string" ? options.responseFormat : options.responseFormat.type;
1266
1325
  if (formatType === "json" || formatType === "json_schema") {
1267
- const schemaText = options.responseFormat.schema || options.responseSchema ? `
1326
+ const schema = options.responseFormat.schema || options.responseSchema;
1327
+ const schemaText = schema ? `
1268
1328
 
1269
- Your output must comply strictly with this JSON Schema: ${JSON.stringify(options.responseFormat.schema || options.responseSchema)}` : "";
1329
+ Your output must comply strictly with this JSON Schema: ${JSON.stringify(schema)}` : "";
1270
1330
  const jsonPrompt = `IMPORTANT: You must respond ONLY in raw JSON format. Do not write any conversational text, explanations, or markdown code blocks (e.g. do NOT wrap your answer in \`\`\`json ... \`\`\`). Your response must start with '{' and end with '}'.${schemaText}`;
1271
- if (payload.system) {
1272
- payload.system = `${payload.system}
1331
+ effectiveSystem = effectiveSystem ? `${effectiveSystem}
1273
1332
 
1274
- ${jsonPrompt}`;
1275
- } else {
1276
- payload.system = jsonPrompt;
1277
- }
1333
+ ${jsonPrompt}` : jsonPrompt;
1278
1334
  }
1279
1335
  }
1280
- let responseText;
1281
- let responseData;
1336
+ const anthropicMessages = this._formatMessages(messages);
1337
+ const anthropicTools = tools ? this._formatTools(tools) : void 0;
1338
+ const requestParams = {
1339
+ model: modelName,
1340
+ max_tokens: maxTokens,
1341
+ temperature,
1342
+ messages: anthropicMessages,
1343
+ ...effectiveSystem ? { system: effectiveSystem } : {},
1344
+ ...anthropicTools ? { tools: anthropicTools } : {}
1345
+ };
1346
+ let response;
1282
1347
  try {
1283
- const res = await fetch(url, {
1284
- method: "POST",
1285
- headers,
1286
- body: JSON.stringify(payload)
1287
- });
1288
- responseText = await res.text();
1289
- if (!res.ok) {
1290
- console.error(`[AnthropicProvider] API request failed with status ${res.status}:`, responseText);
1291
- throw new LLMServiceException(`Anthropic API Error: ${responseText}`, res.status);
1292
- }
1293
- responseData = JSON.parse(responseText);
1348
+ response = await this.client.messages.create(requestParams);
1294
1349
  } catch (error) {
1295
1350
  console.error(`[AnthropicProvider] request failed (API Key: ${this._getMaskedApiKey()}):`, error);
1296
1351
  throw error;
1297
1352
  }
1298
- const contentBlock = responseData.content && responseData.content[0];
1299
- const contentText = contentBlock ? contentBlock.text : "";
1300
- if (!contentText) {
1301
- console.error("[AnthropicProvider] Model returned empty response content");
1353
+ const textBlocks = response.content.filter((b) => b.type === "text");
1354
+ const toolUseBlocks = response.content.filter((b) => b.type === "tool_use");
1355
+ const contentText = textBlocks.map((b) => b.text).join("");
1356
+ if (!contentText && toolUseBlocks.length === 0) {
1357
+ console.error("[AnthropicProvider] Model returned empty response (no text, no tool calls)");
1302
1358
  throw new LLMServiceException(
1303
1359
  "Model returned empty response. This usually means the prompt or schema is confusing the model.",
1304
1360
  500
1305
1361
  );
1306
1362
  }
1307
- const rawFinishReason = responseData.stop_reason;
1363
+ const toolCalls = toolUseBlocks.length > 0 ? toolUseBlocks.map((block) => ({
1364
+ id: block.id,
1365
+ type: "function",
1366
+ function: {
1367
+ name: block.name,
1368
+ // Anthropic returns args as a parsed object; serialize to string to match OpenAI shape
1369
+ arguments: JSON.stringify(block.input || {})
1370
+ }
1371
+ })) : null;
1372
+ const rawFinishReason = response.stop_reason;
1308
1373
  const normalizedFinishReason = this.normalizeFinishReason(rawFinishReason);
1309
1374
  const result = {
1310
1375
  content: contentText,
1311
- tool_calls: null,
1312
- // REST client tool calls mapping not required for playbook studio
1376
+ tool_calls: toolCalls,
1313
1377
  finishReason: normalizedFinishReason,
1314
1378
  _rawFinishReason: rawFinishReason,
1315
1379
  _responseFormat: options.responseFormat,
1316
1380
  usage: {
1317
- prompt_tokens: responseData.usage ? responseData.usage.input_tokens : 0,
1318
- completion_tokens: responseData.usage ? responseData.usage.output_tokens : 0,
1319
- total_tokens: responseData.usage ? responseData.usage.input_tokens + responseData.usage.output_tokens : 0
1381
+ prompt_tokens: ((_a = response.usage) == null ? void 0 : _a.input_tokens) || 0,
1382
+ completion_tokens: ((_b = response.usage) == null ? void 0 : _b.output_tokens) || 0,
1383
+ total_tokens: (((_c = response.usage) == null ? void 0 : _c.input_tokens) || 0) + (((_d = response.usage) == null ? void 0 : _d.output_tokens) || 0)
1320
1384
  },
1321
1385
  model: modelName
1322
1386
  };
@@ -1325,11 +1389,110 @@ ${jsonPrompt}`;
1325
1389
  }
1326
1390
  return result;
1327
1391
  }
1328
- _getModelForTier(tier) {
1329
- if (this.models[tier]) {
1330
- return this.models[tier];
1392
+ /**
1393
+ * Convert standard {role, content, tool_calls, tool_call_id} messages
1394
+ * to Anthropic's message format.
1395
+ *
1396
+ * Key differences from OpenAI:
1397
+ * - No 'system' role in messages array (handled via top-level `system` param)
1398
+ * - Tool results go as role:'user' with content type:'tool_result'
1399
+ * - Assistant messages with tool calls use an array of content blocks
1400
+ * - Roles must strictly alternate user/assistant
1401
+ */
1402
+ _formatMessages(messages) {
1403
+ const result = [];
1404
+ for (const msg of messages) {
1405
+ if (msg.role === "system") {
1406
+ continue;
1407
+ }
1408
+ if (msg.role === "tool") {
1409
+ const toolResultBlock = {
1410
+ type: "tool_result",
1411
+ tool_use_id: msg.tool_call_id,
1412
+ content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
1413
+ };
1414
+ const last = result[result.length - 1];
1415
+ if (last && last.role === "user" && Array.isArray(last.content)) {
1416
+ last.content.push(toolResultBlock);
1417
+ } else {
1418
+ result.push({ role: "user", content: [toolResultBlock] });
1419
+ }
1420
+ continue;
1421
+ }
1422
+ if (msg.role === "assistant") {
1423
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
1424
+ const contentBlocks = [];
1425
+ if (msg.content) {
1426
+ contentBlocks.push({ type: "text", text: msg.content });
1427
+ }
1428
+ for (const tc of msg.tool_calls) {
1429
+ let inputArgs = tc.function.arguments;
1430
+ if (typeof inputArgs === "string") {
1431
+ try {
1432
+ inputArgs = JSON.parse(inputArgs);
1433
+ } catch (_) {
1434
+ inputArgs = {};
1435
+ }
1436
+ }
1437
+ contentBlocks.push({
1438
+ type: "tool_use",
1439
+ id: tc.id,
1440
+ name: tc.function.name,
1441
+ input: inputArgs
1442
+ });
1443
+ }
1444
+ result.push({ role: "assistant", content: contentBlocks });
1445
+ } else {
1446
+ result.push({ role: "assistant", content: msg.content || "" });
1447
+ }
1448
+ continue;
1449
+ }
1450
+ result.push({ role: "user", content: msg.content || "" });
1331
1451
  }
1332
- return this.defaultModel;
1452
+ return result;
1453
+ }
1454
+ /**
1455
+ * Convert OpenAI-style tool definitions to Anthropic format.
1456
+ * OpenAI: { type: 'function', function: { name, description, parameters } }
1457
+ * Anthropic: { name, description, input_schema }
1458
+ */
1459
+ _formatTools(tools) {
1460
+ return tools.map((tool) => {
1461
+ const fn = tool.function || tool;
1462
+ return {
1463
+ name: fn.name,
1464
+ description: fn.description || "",
1465
+ input_schema: fn.parameters || fn.input_schema || { type: "object", properties: {} }
1466
+ };
1467
+ });
1468
+ }
1469
+ async executeTools(tool_calls, messages, tenantId, toolImplementations, env) {
1470
+ const toolResults = await Promise.all(
1471
+ tool_calls.map(async (toolCall) => {
1472
+ const toolName = toolCall.function.name;
1473
+ const tool = toolImplementations[toolName];
1474
+ console.log(`[Tool Call] ${toolName} with arguments:`, toolCall.function.arguments);
1475
+ if (!tool) {
1476
+ console.error(`[Tool Error] Tool '${toolName}' not found`);
1477
+ return { tool_call_id: toolCall.id, output: JSON.stringify({ error: `Tool '${toolName}' not found.` }) };
1478
+ }
1479
+ try {
1480
+ const output = await tool(toolCall.function.arguments, { env, tenantId });
1481
+ const preview = typeof output === "string" ? output.length > 200 ? output.substring(0, 200) + "..." : output : JSON.stringify(output).substring(0, 200) + "...";
1482
+ console.log(`[Tool Result] ${toolName} returned:`, preview);
1483
+ return { tool_call_id: toolCall.id, output };
1484
+ } catch (error) {
1485
+ console.error(`[Tool Error] ${toolName} failed:`, error.message);
1486
+ return { tool_call_id: toolCall.id, output: JSON.stringify({ error: `Error executing tool '${toolName}': ${error.message}` }) };
1487
+ }
1488
+ })
1489
+ );
1490
+ toolResults.forEach(
1491
+ (result) => messages.push({ role: "tool", tool_call_id: result.tool_call_id, content: result.output })
1492
+ );
1493
+ }
1494
+ _getModelForTier(tier) {
1495
+ return this.models[tier] || this.defaultModel;
1333
1496
  }
1334
1497
  _shouldAutoParse(options) {
1335
1498
  if (!options.responseFormat) return false;
@@ -1350,15 +1513,10 @@ ${jsonPrompt}`;
1350
1513
  }
1351
1514
  normalizeFinishReason(providerReason) {
1352
1515
  const lower = (providerReason || "").toLowerCase();
1353
- if (lower === "end_turn") {
1354
- return "completed";
1355
- }
1356
- if (lower === "max_tokens") {
1357
- return "truncated";
1358
- }
1359
- if (lower === "stop_sequence") {
1360
- return "completed";
1361
- }
1516
+ if (lower === "end_turn") return "completed";
1517
+ if (lower === "max_tokens") return "truncated";
1518
+ if (lower === "stop_sequence") return "completed";
1519
+ if (lower === "tool_use") return "tool_call";
1362
1520
  return super.normalizeFinishReason(providerReason);
1363
1521
  }
1364
1522
  };