@framers/agentos 0.12.18 → 0.12.19
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/dist/api/model.d.ts.map +1 -1
- package/dist/api/model.js +2 -0
- package/dist/api/model.js.map +1 -1
- package/dist/api/runtime/provider-defaults.d.ts.map +1 -1
- package/dist/api/runtime/provider-defaults.js +5 -0
- package/dist/api/runtime/provider-defaults.js.map +1 -1
- package/dist/core/config/AgentOSConfig.d.ts +1 -0
- package/dist/core/config/AgentOSConfig.d.ts.map +1 -1
- package/dist/core/config/AgentOSConfig.js +18 -1
- package/dist/core/config/AgentOSConfig.js.map +1 -1
- package/dist/core/llm/providers/AIModelProviderManager.d.ts +2 -1
- package/dist/core/llm/providers/AIModelProviderManager.d.ts.map +1 -1
- package/dist/core/llm/providers/AIModelProviderManager.js +4 -0
- package/dist/core/llm/providers/AIModelProviderManager.js.map +1 -1
- package/dist/core/llm/providers/errors/RequestyProviderError.d.ts +39 -0
- package/dist/core/llm/providers/errors/RequestyProviderError.d.ts.map +1 -0
- package/dist/core/llm/providers/errors/RequestyProviderError.js +42 -0
- package/dist/core/llm/providers/errors/RequestyProviderError.js.map +1 -0
- package/dist/core/llm/providers/implementations/RequestyProvider.d.ts +64 -0
- package/dist/core/llm/providers/implementations/RequestyProvider.d.ts.map +1 -0
- package/dist/core/llm/providers/implementations/RequestyProvider.js +652 -0
- package/dist/core/llm/providers/implementations/RequestyProvider.js.map +1 -0
- package/dist/core/llm/providers/structuredOutputFormat.d.ts +1 -0
- package/dist/core/llm/providers/structuredOutputFormat.d.ts.map +1 -1
- package/dist/core/llm/providers/structuredOutputFormat.js +2 -1
- package/dist/core/llm/providers/structuredOutputFormat.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
// File: backend/agentos/core/llm/providers/implementations/RequestyProvider.ts
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Implements the IProvider interface for Requesty, an OpenAI-compatible
|
|
4
|
+
* LLM gateway that provides access to a wide variety of LLMs from different providers
|
|
5
|
+
* through a unified API. This provider handles routing requests to the specified models
|
|
6
|
+
* via Requesty.
|
|
7
|
+
* @module backend/agentos/core/llm/providers/implementations/RequestyProvider
|
|
8
|
+
* @implements {IProvider}
|
|
9
|
+
*/
|
|
10
|
+
import axios from 'axios';
|
|
11
|
+
import { RequestyProviderError } from '../errors/RequestyProviderError.js';
|
|
12
|
+
import { ApiKeyPool } from '../../../providers/ApiKeyPool.js';
|
|
13
|
+
import { createGMIErrorFromError, GMIErrorCode } from '../../../utils/errors.js'; // Corrected import path
|
|
14
|
+
import { clampMaxOutputTokens } from '../model-output-limits.js';
|
|
15
|
+
export class RequestyProvider {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.providerId = 'requesty';
|
|
18
|
+
this.isInitialized = false;
|
|
19
|
+
this.keyPool = null;
|
|
20
|
+
this.availableModelsCache = new Map();
|
|
21
|
+
}
|
|
22
|
+
async initialize(config) {
|
|
23
|
+
if (!config.apiKey) {
|
|
24
|
+
throw new RequestyProviderError('Requesty API key (apiKey) is required for initialization.', 'INIT_FAILED_MISSING_API_KEY');
|
|
25
|
+
}
|
|
26
|
+
// Corrected: Ensure all properties of Required<RequestyProviderConfig> are present
|
|
27
|
+
// by providing defaults for optional fields before freezing.
|
|
28
|
+
this.config = Object.freeze({
|
|
29
|
+
apiKey: config.apiKey,
|
|
30
|
+
baseURL: config.baseURL || 'https://router.requesty.ai/v1',
|
|
31
|
+
defaultModelId: config.defaultModelId, // Can be undefined, but this.defaultModelId will store it
|
|
32
|
+
siteUrl: config.siteUrl, // Can be undefined
|
|
33
|
+
appName: config.appName, // Can be undefined
|
|
34
|
+
requestTimeout: config.requestTimeout || 60000,
|
|
35
|
+
streamRequestTimeout: config.streamRequestTimeout || 180000,
|
|
36
|
+
});
|
|
37
|
+
this.keyPool = new ApiKeyPool(config.apiKey);
|
|
38
|
+
this.defaultModelId = this.config.defaultModelId; // Store the potentially undefined value
|
|
39
|
+
// No Authorization header here: the key is resolved per request inside
|
|
40
|
+
// makeApiRequest (apiKeyOverride first, then the pool), so key rotation
|
|
41
|
+
// and per call overrides are honored instead of a key baked at init.
|
|
42
|
+
const headers = {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
'User-Agent': `AgentOS/1.0 (RequestyProvider; ${this.config.appName || 'UnknownApp'})`,
|
|
45
|
+
};
|
|
46
|
+
if (this.config.siteUrl) {
|
|
47
|
+
headers['HTTP-Referer'] = this.config.siteUrl;
|
|
48
|
+
}
|
|
49
|
+
if (this.config.appName) {
|
|
50
|
+
headers['X-Title'] = this.config.appName;
|
|
51
|
+
}
|
|
52
|
+
this.client = axios.create({
|
|
53
|
+
baseURL: this.config.baseURL,
|
|
54
|
+
headers,
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
await this.refreshAvailableModels();
|
|
58
|
+
this.isInitialized = true;
|
|
59
|
+
console.log(`RequestyProvider initialized. Default Model: ${this.defaultModelId || 'Not set'}. Found ${this.availableModelsCache.size} models via Requesty.`);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.isInitialized = false;
|
|
63
|
+
const initError = error instanceof RequestyProviderError ? error :
|
|
64
|
+
createGMIErrorFromError(// Corrected: use imported createGMIErrorFromError
|
|
65
|
+
error instanceof Error ? error : new Error(String(error)), GMIErrorCode.LLM_PROVIDER_ERROR, // Corrected: use imported GMIErrorCode
|
|
66
|
+
{ providerId: this.providerId }, `RequestyProvider failed to initialize: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
console.error(initError.message, initError.details || initError);
|
|
68
|
+
throw initError;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async refreshAvailableModels() {
|
|
72
|
+
const responseData = await this.makeApiRequest('/models', 'GET', this.config.requestTimeout);
|
|
73
|
+
this.availableModelsCache.clear();
|
|
74
|
+
if (responseData && Array.isArray(responseData.data)) {
|
|
75
|
+
responseData.data.forEach((apiModel) => {
|
|
76
|
+
const modelInfo = this.mapApiToModelInfo(apiModel);
|
|
77
|
+
this.availableModelsCache.set(modelInfo.modelId, modelInfo);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.warn("RequestyProvider: Received no model data or malformed response from /models endpoint.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
mapApiToModelInfo(apiModel) {
|
|
85
|
+
const capabilities = ['chat', 'completion'];
|
|
86
|
+
if (apiModel.architecture?.modality === 'multimodal') {
|
|
87
|
+
capabilities.push('vision_input');
|
|
88
|
+
}
|
|
89
|
+
const knownAdvancedModelPatterns = ['gpt-3.5', 'gpt-4', 'claude-2', 'claude-3', 'gemini', 'mistral', 'llama'];
|
|
90
|
+
if (knownAdvancedModelPatterns.some(pattern => apiModel.id.toLowerCase().includes(pattern))) {
|
|
91
|
+
capabilities.push('tool_use', 'json_mode');
|
|
92
|
+
}
|
|
93
|
+
if (apiModel.id.includes('embedding') || apiModel.id.includes('embed')) {
|
|
94
|
+
capabilities.push('embeddings');
|
|
95
|
+
}
|
|
96
|
+
const parsePrice = (priceStr, tokensFactor = 1000000) => {
|
|
97
|
+
if (typeof priceStr !== 'string')
|
|
98
|
+
return undefined;
|
|
99
|
+
const price = parseFloat(priceStr);
|
|
100
|
+
return isNaN(price) ? undefined : price * tokensFactor;
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
modelId: apiModel.id,
|
|
104
|
+
providerId: this.providerId,
|
|
105
|
+
displayName: apiModel.name,
|
|
106
|
+
description: apiModel.description,
|
|
107
|
+
capabilities: Array.from(new Set(capabilities)),
|
|
108
|
+
contextWindowSize: apiModel.context_length || undefined,
|
|
109
|
+
pricePer1MTokensInput: parsePrice(apiModel.pricing?.prompt),
|
|
110
|
+
pricePer1MTokensOutput: parsePrice(apiModel.pricing?.completion),
|
|
111
|
+
supportsStreaming: true,
|
|
112
|
+
status: 'active',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
ensureInitialized() {
|
|
116
|
+
if (!this.isInitialized) {
|
|
117
|
+
throw new RequestyProviderError('RequestyProvider is not initialized. Please call the initialize() method first.', 'PROVIDER_NOT_INITIALIZED');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
mapToRequestyMessages(messages) {
|
|
121
|
+
return messages.map(msg => {
|
|
122
|
+
const mappedMsg = { role: msg.role, content: msg.content };
|
|
123
|
+
if (msg.name)
|
|
124
|
+
mappedMsg.name = msg.name;
|
|
125
|
+
if (msg.tool_calls)
|
|
126
|
+
mappedMsg.tool_calls = msg.tool_calls;
|
|
127
|
+
if (msg.tool_call_id)
|
|
128
|
+
mappedMsg.tool_call_id = msg.tool_call_id;
|
|
129
|
+
return mappedMsg;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async generateCompletion(modelId, messages, options) {
|
|
133
|
+
this.ensureInitialized();
|
|
134
|
+
const requestyMessages = this.mapToRequestyMessages(messages);
|
|
135
|
+
const payload = {
|
|
136
|
+
model: modelId,
|
|
137
|
+
messages: requestyMessages,
|
|
138
|
+
stream: false,
|
|
139
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
140
|
+
...(options.topP !== undefined && { top_p: options.topP }),
|
|
141
|
+
// Requesty reserves credits up to max_tokens at request time. When the
|
|
142
|
+
// caller doesn't specify a limit, it falls back to the model's full output
|
|
143
|
+
// capacity (e.g. 64000 for claude-haiku-4-5), which causes 402 credit-required
|
|
144
|
+
// errors on accounts without enough buffer. Default to 4096, the same value
|
|
145
|
+
// AnthropicProvider uses, so short prompts succeed without explicit tuning.
|
|
146
|
+
// Then clamp to the model's output ceiling so a request sized for a
|
|
147
|
+
// flagship model is not rejected when routed to a lower-ceiling OpenAI
|
|
148
|
+
// model (e.g. openai/gpt-4o caps at 16384, not 32000).
|
|
149
|
+
max_tokens: clampMaxOutputTokens(modelId, options.maxTokens) ?? 4096,
|
|
150
|
+
...(options.presencePenalty !== undefined && { presence_penalty: options.presencePenalty }),
|
|
151
|
+
...(options.frequencyPenalty !== undefined && { frequency_penalty: options.frequencyPenalty }),
|
|
152
|
+
...(options.stopSequences !== undefined && { stop: options.stopSequences }),
|
|
153
|
+
...(options.userId !== undefined && { user: options.userId }),
|
|
154
|
+
...(options.tools !== undefined && { tools: options.tools }),
|
|
155
|
+
...(options.toolChoice !== undefined && { tool_choice: options.toolChoice }),
|
|
156
|
+
...(options.responseFormat?.type === 'json_object' && { response_format: { type: 'json_object' } }),
|
|
157
|
+
...(options.customModelParams || {}),
|
|
158
|
+
};
|
|
159
|
+
const apiResponseData = await this.makeApiRequest('/chat/completions', 'POST',
|
|
160
|
+
// CR8: honor a per-call requestTimeout override over the provider default.
|
|
161
|
+
options.requestTimeout ?? this.config.requestTimeout, payload, false, { apiKeyOverride: options.apiKeyOverride, signal: options.abortSignal });
|
|
162
|
+
return this.mapApiToCompletionResponse(apiResponseData, modelId);
|
|
163
|
+
}
|
|
164
|
+
async *generateCompletionStream(modelId, messages, options) {
|
|
165
|
+
this.ensureInitialized();
|
|
166
|
+
const requestyMessages = this.mapToRequestyMessages(messages);
|
|
167
|
+
const payload = {
|
|
168
|
+
model: modelId,
|
|
169
|
+
messages: requestyMessages,
|
|
170
|
+
stream: true,
|
|
171
|
+
// Requesty follows the OpenAI streaming convention: usage is omitted
|
|
172
|
+
// unless stream_options.include_usage is set, in which case a trailing
|
|
173
|
+
// usage-only chunk arrives before [DONE]. Without this flag,
|
|
174
|
+
// streamText({...}).usage resolves to all zeros even on success.
|
|
175
|
+
stream_options: { include_usage: true },
|
|
176
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
177
|
+
...(options.topP !== undefined && { top_p: options.topP }),
|
|
178
|
+
// Requesty reserves credits up to max_tokens at request time. When the
|
|
179
|
+
// caller doesn't specify a limit, it falls back to the model's full output
|
|
180
|
+
// capacity (e.g. 64000 for claude-haiku-4-5), which causes 402 credit-required
|
|
181
|
+
// errors on accounts without enough buffer. Default to 4096, the same value
|
|
182
|
+
// AnthropicProvider uses, so short prompts succeed without explicit tuning.
|
|
183
|
+
// Then clamp to the model's output ceiling so a request sized for a
|
|
184
|
+
// flagship model is not rejected when routed to a lower-ceiling OpenAI
|
|
185
|
+
// model (e.g. openai/gpt-4o caps at 16384, not 32000).
|
|
186
|
+
max_tokens: clampMaxOutputTokens(modelId, options.maxTokens) ?? 4096,
|
|
187
|
+
...(options.presencePenalty !== undefined && { presence_penalty: options.presencePenalty }),
|
|
188
|
+
...(options.frequencyPenalty !== undefined && { frequency_penalty: options.frequencyPenalty }),
|
|
189
|
+
...(options.stopSequences !== undefined && { stop: options.stopSequences }),
|
|
190
|
+
...(options.userId !== undefined && { user: options.userId }),
|
|
191
|
+
...(options.tools !== undefined && { tools: options.tools }),
|
|
192
|
+
...(options.toolChoice !== undefined && { tool_choice: options.toolChoice }),
|
|
193
|
+
...(options.responseFormat?.type === 'json_object' && { response_format: { type: 'json_object' } }),
|
|
194
|
+
...(options.customModelParams || {}),
|
|
195
|
+
};
|
|
196
|
+
const abortSignal = options.abortSignal;
|
|
197
|
+
if (abortSignal?.aborted) {
|
|
198
|
+
yield { id: `requesty-abort-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), modelId, choices: [], error: { message: 'Stream aborted prior to first chunk', type: 'abort' }, isFinal: true };
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const accumulatedToolCalls = new Map();
|
|
202
|
+
// Stream contract (see StreamingReconstructor.ts): exactly one chunk per
|
|
203
|
+
// stream carries isFinal:true. With stream_options.include_usage the API
|
|
204
|
+
// sends the finish_reason chunk and then a usage-only chunk, so the
|
|
205
|
+
// finish_reason chunk is buffered here and merged with the usage chunk
|
|
206
|
+
// before a single final chunk is emitted.
|
|
207
|
+
let pendingFinal;
|
|
208
|
+
// Some upstreams routed through Requesty end the stream at [DONE] without
|
|
209
|
+
// ever sending finish_reason. Track whether a final chunk went out so one
|
|
210
|
+
// can be synthesized in that case.
|
|
211
|
+
let finalEmitted = false;
|
|
212
|
+
try {
|
|
213
|
+
const stream = await this.makeApiRequest('/chat/completions', 'POST',
|
|
214
|
+
// CR8: honor a per-call requestTimeout override over the stream default.
|
|
215
|
+
options.requestTimeout ?? this.config.streamRequestTimeout, payload, true, { apiKeyOverride: options.apiKeyOverride, signal: abortSignal });
|
|
216
|
+
for await (const rawChunk of this.parseSseStream(stream)) {
|
|
217
|
+
if (abortSignal?.aborted) {
|
|
218
|
+
pendingFinal = undefined;
|
|
219
|
+
finalEmitted = true;
|
|
220
|
+
yield { id: `requesty-abort-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), modelId, choices: [], error: { message: 'Stream aborted by caller', type: 'abort' }, isFinal: true };
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if (rawChunk.startsWith('data: ') && rawChunk.includes('[DONE]')) {
|
|
224
|
+
const doneData = rawChunk.substring('data: '.length).trim();
|
|
225
|
+
if (doneData === '[DONE]')
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
if (rawChunk === 'data: [DONE]') {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
if (rawChunk.startsWith('data: ')) {
|
|
232
|
+
const jsonData = rawChunk.substring('data: '.length);
|
|
233
|
+
let apiChunk;
|
|
234
|
+
try {
|
|
235
|
+
apiChunk = JSON.parse(jsonData);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
// Do not log or attach the raw payload: it can hold completion
|
|
239
|
+
// text or tool arguments. Only the length is safe metadata.
|
|
240
|
+
throw new RequestyProviderError(`Malformed SSE data chunk from Requesty (${jsonData.length} bytes could not be parsed as JSON).`, 'STREAM_CHUNK_MALFORMED', undefined, undefined, { payloadLength: jsonData.length, parseError: error instanceof Error ? error.message : String(error) });
|
|
241
|
+
}
|
|
242
|
+
const mapped = this.mapApiToStreamChunkResponse(apiChunk, modelId, accumulatedToolCalls);
|
|
243
|
+
// Don't break on finish_reason: with stream_options.include_usage,
|
|
244
|
+
// Requesty (like OpenAI) emits a trailing usage-only chunk AFTER
|
|
245
|
+
// the finish_reason chunk and BEFORE [DONE]. Breaking here would
|
|
246
|
+
// skip the usage chunk and zero out the caller's token totals. The
|
|
247
|
+
// [DONE] marker check above is the right termination signal.
|
|
248
|
+
if (!mapped.isFinal) {
|
|
249
|
+
yield mapped;
|
|
250
|
+
}
|
|
251
|
+
else if (mapped.choices.length > 0) {
|
|
252
|
+
pendingFinal = mapped;
|
|
253
|
+
}
|
|
254
|
+
else if (pendingFinal) {
|
|
255
|
+
finalEmitted = true;
|
|
256
|
+
yield { ...pendingFinal, usage: mapped.usage ?? pendingFinal.usage };
|
|
257
|
+
pendingFinal = undefined;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
finalEmitted = true;
|
|
261
|
+
yield mapped;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (pendingFinal) {
|
|
266
|
+
// [DONE] (or end of stream) arrived without a usage-only chunk.
|
|
267
|
+
finalEmitted = true;
|
|
268
|
+
yield pendingFinal;
|
|
269
|
+
}
|
|
270
|
+
else if (!finalEmitted) {
|
|
271
|
+
// Stream ended without any finish_reason or usage chunk. Emit the
|
|
272
|
+
// terminal chunk the provider contract requires so consumers see
|
|
273
|
+
// completion.
|
|
274
|
+
yield {
|
|
275
|
+
id: `requesty-final-${Date.now()}`,
|
|
276
|
+
object: 'chat.completion.chunk',
|
|
277
|
+
created: Math.floor(Date.now() / 1000),
|
|
278
|
+
modelId,
|
|
279
|
+
choices: [{ index: 0, message: { role: 'assistant', content: null }, finishReason: 'stop' }],
|
|
280
|
+
isFinal: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// Provider contract (IProvider) requires a terminal isFinal:true chunk
|
|
286
|
+
// even on error. If request setup or SSE iteration throws, surface it as
|
|
287
|
+
// a final error chunk instead of letting the generator throw.
|
|
288
|
+
if (abortSignal?.aborted) {
|
|
289
|
+
yield { id: `requesty-abort-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), modelId, choices: [], error: { message: 'Stream aborted by caller', type: 'abort' }, isFinal: true };
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
293
|
+
yield { id: `requesty-error-${Date.now()}`, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), modelId, choices: [], error: { message, type: 'api_error' }, isFinal: true };
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async generateEmbeddings(modelId, texts, options) {
|
|
298
|
+
this.ensureInitialized();
|
|
299
|
+
if (!texts || texts.length === 0) {
|
|
300
|
+
throw new RequestyProviderError('Input texts array cannot be empty for generating embeddings.', 'EMBEDDING_NO_INPUT');
|
|
301
|
+
}
|
|
302
|
+
const modelInfo = await this.getModelInfo(modelId);
|
|
303
|
+
if (modelInfo && !modelInfo.capabilities.includes('embeddings')) {
|
|
304
|
+
console.warn(`RequestyProvider: Model '${modelId}' is not explicitly listed with embedding capabilities. Attempting anyway.`);
|
|
305
|
+
}
|
|
306
|
+
const payload = {
|
|
307
|
+
model: modelId,
|
|
308
|
+
input: texts,
|
|
309
|
+
...(options?.encodingFormat && { encoding_format: options.encodingFormat }),
|
|
310
|
+
...(options?.dimensions && { dimensions: options.dimensions }),
|
|
311
|
+
...(options?.inputType && { input_type: options.inputType }),
|
|
312
|
+
...(options?.customModelParams || {}),
|
|
313
|
+
};
|
|
314
|
+
const apiResponseData = await this.makeApiRequest('/embeddings', 'POST', this.config.requestTimeout, payload, false, { apiKeyOverride: options?.apiKeyOverride });
|
|
315
|
+
if (!Array.isArray(apiResponseData?.data) || !apiResponseData.usage) {
|
|
316
|
+
throw new RequestyProviderError('Malformed embeddings response from Requesty: missing data array or usage.', 'API_RESPONSE_MALFORMED', undefined, undefined, { hasData: Array.isArray(apiResponseData?.data), hasUsage: !!apiResponseData?.usage });
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
object: 'list',
|
|
320
|
+
data: apiResponseData.data.map(d => ({
|
|
321
|
+
object: 'embedding',
|
|
322
|
+
embedding: d.embedding,
|
|
323
|
+
index: d.index,
|
|
324
|
+
})),
|
|
325
|
+
model: apiResponseData.model,
|
|
326
|
+
usage: {
|
|
327
|
+
prompt_tokens: apiResponseData.usage.prompt_tokens,
|
|
328
|
+
total_tokens: apiResponseData.usage.total_tokens,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
async listAvailableModels(filter) {
|
|
333
|
+
this.ensureInitialized();
|
|
334
|
+
if (this.availableModelsCache.size === 0) {
|
|
335
|
+
try {
|
|
336
|
+
await this.refreshAvailableModels();
|
|
337
|
+
}
|
|
338
|
+
catch (refreshError) {
|
|
339
|
+
console.warn("RequestyProvider: Failed to refresh models during listAvailableModels call after finding empty cache:", refreshError);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const models = Array.from(this.availableModelsCache.values());
|
|
343
|
+
if (filter?.capability) {
|
|
344
|
+
return models.filter(m => m.capabilities.includes(filter.capability));
|
|
345
|
+
}
|
|
346
|
+
return models;
|
|
347
|
+
}
|
|
348
|
+
async getModelInfo(modelId) {
|
|
349
|
+
this.ensureInitialized();
|
|
350
|
+
if (!this.availableModelsCache.has(modelId)) {
|
|
351
|
+
try {
|
|
352
|
+
console.log(`RequestyProvider: Model ${modelId} not in cache. Refreshing model list.`);
|
|
353
|
+
await this.refreshAvailableModels();
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
console.warn(`RequestyProvider: Failed to refresh models list while trying to get info for ${modelId}:`, error);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return this.availableModelsCache.get(modelId);
|
|
360
|
+
}
|
|
361
|
+
async checkHealth() {
|
|
362
|
+
if (!this.client) {
|
|
363
|
+
return { isHealthy: false, details: { message: "RequestyProvider not initialized (HTTP client missing)." } };
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
// Auth rides per request since the key is no longer on the axios
|
|
367
|
+
// instance; keep the health probe representative of real traffic.
|
|
368
|
+
await this.client.get('/models', {
|
|
369
|
+
timeout: Math.min(this.config.requestTimeout || 10000, 10000),
|
|
370
|
+
headers: { Authorization: `Bearer ${this.resolveApiKey()}` },
|
|
371
|
+
});
|
|
372
|
+
return { isHealthy: true, details: { message: "Successfully connected to Requesty /models endpoint." } };
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
const err = error;
|
|
376
|
+
return {
|
|
377
|
+
isHealthy: false,
|
|
378
|
+
details: {
|
|
379
|
+
message: `Requesty health check failed: ${err.message}`,
|
|
380
|
+
status: err.response?.status,
|
|
381
|
+
responseData: err.response?.data,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async shutdown() {
|
|
387
|
+
this.isInitialized = false;
|
|
388
|
+
this.availableModelsCache.clear();
|
|
389
|
+
console.log('RequestyProvider shutdown: Instance marked as uninitialized and cache cleared.');
|
|
390
|
+
}
|
|
391
|
+
mapApiToCompletionResponse(apiResponse, requestedModelId) {
|
|
392
|
+
const choice = apiResponse.choices[0];
|
|
393
|
+
if (!choice) {
|
|
394
|
+
throw new RequestyProviderError("Received empty choices array from Requesty.", "API_RESPONSE_MALFORMED", undefined, undefined, { responseId: apiResponse.id });
|
|
395
|
+
}
|
|
396
|
+
const choiceWithoutMessage = apiResponse.choices.find(c => !c.message || typeof c.message !== 'object');
|
|
397
|
+
if (choiceWithoutMessage) {
|
|
398
|
+
throw new RequestyProviderError(`Choice ${choiceWithoutMessage.index} in Requesty response has no message object.`, "API_RESPONSE_MALFORMED", undefined, undefined, { responseId: apiResponse.id, choiceIndex: choiceWithoutMessage.index });
|
|
399
|
+
}
|
|
400
|
+
const usage = apiResponse.usage ? {
|
|
401
|
+
promptTokens: apiResponse.usage.prompt_tokens,
|
|
402
|
+
completionTokens: apiResponse.usage.completion_tokens,
|
|
403
|
+
totalTokens: apiResponse.usage.total_tokens,
|
|
404
|
+
costUSD: apiResponse.usage.cost,
|
|
405
|
+
} : undefined;
|
|
406
|
+
return {
|
|
407
|
+
id: apiResponse.id,
|
|
408
|
+
object: apiResponse.object,
|
|
409
|
+
created: apiResponse.created,
|
|
410
|
+
modelId: apiResponse.model || requestedModelId,
|
|
411
|
+
choices: apiResponse.choices.map(c => ({
|
|
412
|
+
index: c.index,
|
|
413
|
+
message: {
|
|
414
|
+
role: c.message.role,
|
|
415
|
+
content: c.message.content,
|
|
416
|
+
tool_calls: c.message.tool_calls,
|
|
417
|
+
},
|
|
418
|
+
finishReason: c.finish_reason,
|
|
419
|
+
logprobs: c.logprobs,
|
|
420
|
+
})),
|
|
421
|
+
usage,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
mapApiToStreamChunkResponse(apiChunk, requestedModelId, accumulatedToolCalls) {
|
|
425
|
+
const choice = apiChunk.choices[0];
|
|
426
|
+
// With stream_options.include_usage, Requesty (like OpenAI) emits a
|
|
427
|
+
// trailing chunk with an empty choices array and a populated usage
|
|
428
|
+
// object. Recognize it as a final usage-only chunk so callers see real
|
|
429
|
+
// token totals after the stream resolves. Without this, the empty-choices
|
|
430
|
+
// path below would mark it as a malformed-response error.
|
|
431
|
+
if ((!apiChunk.choices || apiChunk.choices.length === 0) && apiChunk.usage) {
|
|
432
|
+
return {
|
|
433
|
+
id: apiChunk.id,
|
|
434
|
+
object: apiChunk.object,
|
|
435
|
+
created: apiChunk.created,
|
|
436
|
+
modelId: apiChunk.model || requestedModelId,
|
|
437
|
+
choices: [],
|
|
438
|
+
isFinal: true,
|
|
439
|
+
usage: {
|
|
440
|
+
promptTokens: apiChunk.usage.prompt_tokens,
|
|
441
|
+
completionTokens: apiChunk.usage.completion_tokens,
|
|
442
|
+
totalTokens: apiChunk.usage.total_tokens,
|
|
443
|
+
costUSD: apiChunk.usage.cost,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (!choice) {
|
|
448
|
+
return {
|
|
449
|
+
id: apiChunk.id, object: apiChunk.object, created: apiChunk.created,
|
|
450
|
+
modelId: apiChunk.model || requestedModelId, choices: [], isFinal: true,
|
|
451
|
+
error: { message: "Stream chunk contained no choices.", type: "invalid_response" }
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
let responseTextDelta;
|
|
455
|
+
let toolCallsDeltas;
|
|
456
|
+
if (choice.delta?.content) {
|
|
457
|
+
responseTextDelta = choice.delta.content;
|
|
458
|
+
}
|
|
459
|
+
if (choice.delta?.tool_calls) {
|
|
460
|
+
toolCallsDeltas = [];
|
|
461
|
+
choice.delta.tool_calls.forEach(tcDelta => {
|
|
462
|
+
let currentToolCallState = accumulatedToolCalls.get(tcDelta.index);
|
|
463
|
+
if (!currentToolCallState) {
|
|
464
|
+
currentToolCallState = { function: { name: '', arguments: '' } };
|
|
465
|
+
}
|
|
466
|
+
if (tcDelta.id)
|
|
467
|
+
currentToolCallState.id = tcDelta.id;
|
|
468
|
+
if (tcDelta.type)
|
|
469
|
+
currentToolCallState.type = tcDelta.type;
|
|
470
|
+
if (tcDelta.function?.name)
|
|
471
|
+
currentToolCallState.function.name = (currentToolCallState.function.name || '') + tcDelta.function.name;
|
|
472
|
+
if (tcDelta.function?.arguments)
|
|
473
|
+
currentToolCallState.function.arguments = (currentToolCallState.function.arguments || '') + tcDelta.function.arguments;
|
|
474
|
+
accumulatedToolCalls.set(tcDelta.index, currentToolCallState);
|
|
475
|
+
toolCallsDeltas.push({
|
|
476
|
+
index: tcDelta.index,
|
|
477
|
+
id: tcDelta.id,
|
|
478
|
+
type: tcDelta.type,
|
|
479
|
+
function: tcDelta.function ? {
|
|
480
|
+
name: tcDelta.function.name,
|
|
481
|
+
arguments_delta: tcDelta.function.arguments
|
|
482
|
+
} : undefined,
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const isFinal = !!choice.finish_reason;
|
|
487
|
+
let finalUsage;
|
|
488
|
+
const finalChoices = [];
|
|
489
|
+
if (isFinal) {
|
|
490
|
+
if (apiChunk.usage) {
|
|
491
|
+
finalUsage = {
|
|
492
|
+
promptTokens: apiChunk.usage.prompt_tokens,
|
|
493
|
+
completionTokens: apiChunk.usage.completion_tokens,
|
|
494
|
+
totalTokens: apiChunk.usage.total_tokens,
|
|
495
|
+
costUSD: apiChunk.usage.cost,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const finalMessage = {
|
|
499
|
+
role: choice.delta?.role || (accumulatedToolCalls.size > 0 ? 'assistant' : (choice.message?.role || 'assistant')),
|
|
500
|
+
content: responseTextDelta || (choice.message?.content || null),
|
|
501
|
+
tool_calls: Array.from(accumulatedToolCalls.values())
|
|
502
|
+
.filter(tc => tc.id && tc.function?.name)
|
|
503
|
+
.map(accTc => ({
|
|
504
|
+
id: accTc.id,
|
|
505
|
+
type: accTc.type,
|
|
506
|
+
function: { name: accTc.function.name, arguments: accTc.function.arguments }
|
|
507
|
+
})),
|
|
508
|
+
};
|
|
509
|
+
if (!finalMessage.tool_calls || finalMessage.tool_calls.length === 0) {
|
|
510
|
+
delete finalMessage.tool_calls;
|
|
511
|
+
}
|
|
512
|
+
if (responseTextDelta && !choice.message?.content && accumulatedToolCalls.size === 0) {
|
|
513
|
+
finalMessage.content = responseTextDelta;
|
|
514
|
+
}
|
|
515
|
+
else if (accumulatedToolCalls.size > 0 && !responseTextDelta && !choice.message?.content) {
|
|
516
|
+
finalMessage.content = null;
|
|
517
|
+
}
|
|
518
|
+
finalChoices.push({
|
|
519
|
+
index: choice.index,
|
|
520
|
+
message: finalMessage,
|
|
521
|
+
finishReason: choice.finish_reason,
|
|
522
|
+
logprobs: choice.logprobs,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
finalChoices.push({
|
|
527
|
+
index: choice.index,
|
|
528
|
+
message: {
|
|
529
|
+
role: choice.delta?.role || 'assistant',
|
|
530
|
+
content: responseTextDelta || null,
|
|
531
|
+
},
|
|
532
|
+
finishReason: null,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
id: apiChunk.id,
|
|
537
|
+
object: apiChunk.object,
|
|
538
|
+
created: apiChunk.created,
|
|
539
|
+
modelId: apiChunk.model || requestedModelId,
|
|
540
|
+
choices: finalChoices,
|
|
541
|
+
responseTextDelta: isFinal ? undefined : responseTextDelta,
|
|
542
|
+
toolCallsDeltas: isFinal ? undefined : toolCallsDeltas,
|
|
543
|
+
isFinal,
|
|
544
|
+
usage: finalUsage,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Resolve the bearer key for one request: a per call override wins,
|
|
549
|
+
* otherwise the next key from the pool (weighted rotation).
|
|
550
|
+
*/
|
|
551
|
+
resolveApiKey(apiKeyOverride) {
|
|
552
|
+
if (apiKeyOverride)
|
|
553
|
+
return apiKeyOverride;
|
|
554
|
+
return this.keyPool?.hasKeys ? this.keyPool.next() : this.config.apiKey;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Reduce an unknown thrown value to fields that are safe to attach to
|
|
558
|
+
* error details and to log. The raw Axios error keeps the request config
|
|
559
|
+
* (Authorization header, serialized body), so it is never stored as is.
|
|
560
|
+
*/
|
|
561
|
+
sanitizeUnderlyingError(error) {
|
|
562
|
+
if (error instanceof Error) {
|
|
563
|
+
const code = error.code;
|
|
564
|
+
return {
|
|
565
|
+
name: error.name,
|
|
566
|
+
...(typeof code === 'string' ? { code } : {}),
|
|
567
|
+
message: error.message,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return { message: String(error) };
|
|
571
|
+
}
|
|
572
|
+
async makeApiRequest(endpoint, method, timeout, body, expectStream = false, perRequest) {
|
|
573
|
+
try {
|
|
574
|
+
const response = await this.client.request({
|
|
575
|
+
url: endpoint,
|
|
576
|
+
method,
|
|
577
|
+
data: body,
|
|
578
|
+
timeout: timeout,
|
|
579
|
+
headers: { Authorization: `Bearer ${this.resolveApiKey(perRequest?.apiKeyOverride)}` },
|
|
580
|
+
// Forward the caller's AbortSignal so an abort settles the request
|
|
581
|
+
// while Axios is still waiting for headers or the next body chunk.
|
|
582
|
+
...(perRequest?.signal ? { signal: perRequest.signal } : {}),
|
|
583
|
+
responseType: expectStream ? 'stream' : 'json',
|
|
584
|
+
});
|
|
585
|
+
return response.data;
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
let statusCode;
|
|
589
|
+
let errorData;
|
|
590
|
+
let errorMessage = 'Unknown Requesty API error';
|
|
591
|
+
let errorType = 'UNKNOWN_API_ERROR';
|
|
592
|
+
if (axios.isAxiosError(error)) {
|
|
593
|
+
statusCode = error.response?.status;
|
|
594
|
+
errorData = error.response?.data;
|
|
595
|
+
if (errorData?.error && typeof errorData.error === 'object') {
|
|
596
|
+
errorMessage = errorData.error.message || errorMessage;
|
|
597
|
+
errorType = errorData.error.type || errorType;
|
|
598
|
+
}
|
|
599
|
+
else if (typeof errorData === 'string') {
|
|
600
|
+
errorMessage = errorData;
|
|
601
|
+
}
|
|
602
|
+
else if (error.message) {
|
|
603
|
+
errorMessage = error.message;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else if (error instanceof Error) {
|
|
607
|
+
errorMessage = error.message;
|
|
608
|
+
}
|
|
609
|
+
// Prefix the status code into the message so downstream retry/fallback
|
|
610
|
+
// logic (e.g. isRetryableError, which greps for \b402\b) can route on it
|
|
611
|
+
// even when the Requesty API body provides a friendlier description.
|
|
612
|
+
const decoratedMessage = statusCode ? `[${statusCode}] ${errorMessage}` : errorMessage;
|
|
613
|
+
throw new RequestyProviderError(decoratedMessage, 'API_REQUEST_FAILED', statusCode, errorType, { requestEndpoint: endpoint, requestBodyKeys: body ? Object.keys(body) : undefined, responseData: errorData, underlyingError: this.sanitizeUnderlyingError(error) });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async *parseSseStream(stream) {
|
|
617
|
+
let buffer = '';
|
|
618
|
+
const readableStream = stream;
|
|
619
|
+
try {
|
|
620
|
+
for await (const chunk of readableStream) {
|
|
621
|
+
buffer += chunk.toString();
|
|
622
|
+
let eolIndex;
|
|
623
|
+
while ((eolIndex = buffer.indexOf('\n')) >= 0) {
|
|
624
|
+
const line = buffer.substring(0, eolIndex).trim();
|
|
625
|
+
buffer = buffer.substring(eolIndex + 1);
|
|
626
|
+
if (line) {
|
|
627
|
+
yield line;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (buffer.trim()) {
|
|
632
|
+
yield buffer.trim();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
const message = error instanceof Error ? error.message : "Requesty stream parsing/reading error";
|
|
637
|
+
console.error("RequestyProvider: Error reading or parsing SSE stream:", message);
|
|
638
|
+
if (error instanceof RequestyProviderError)
|
|
639
|
+
throw error;
|
|
640
|
+
throw new RequestyProviderError(message, 'STREAM_PARSING_ERROR', undefined, undefined, this.sanitizeUnderlyingError(error));
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
if (typeof readableStream.destroy === 'function') {
|
|
644
|
+
readableStream.destroy();
|
|
645
|
+
}
|
|
646
|
+
else if (typeof readableStream.close === 'function') {
|
|
647
|
+
readableStream.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
//# sourceMappingURL=RequestyProvider.js.map
|