@vybestack/llxprt-code-core 0.6.2 → 0.7.0-nightly.251206.43b97dbf4
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/src/auth/precedence.js +9 -10
- package/dist/src/auth/precedence.js.map +1 -1
- package/dist/src/auth/types.d.ts +6 -6
- package/dist/src/core/geminiChat.d.ts +8 -0
- package/dist/src/core/geminiChat.js +63 -5
- package/dist/src/core/geminiChat.js.map +1 -1
- package/dist/src/core/turn.js +12 -8
- package/dist/src/core/turn.js.map +1 -1
- package/dist/src/ide/ide-client.js +4 -2
- package/dist/src/ide/ide-client.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/parsers/TextToolCallParser.d.ts +0 -15
- package/dist/src/parsers/TextToolCallParser.js +21 -5
- package/dist/src/parsers/TextToolCallParser.js.map +1 -1
- package/dist/src/providers/BaseProvider.d.ts +3 -0
- package/dist/src/providers/BaseProvider.js +11 -0
- package/dist/src/providers/BaseProvider.js.map +1 -1
- package/dist/src/providers/IProvider.d.ts +3 -0
- package/dist/src/providers/ProviderManager.js +6 -0
- package/dist/src/providers/ProviderManager.js.map +1 -1
- package/dist/src/providers/anthropic/AnthropicProvider.d.ts +0 -1
- package/dist/src/providers/anthropic/AnthropicProvider.js +233 -22
- package/dist/src/providers/anthropic/AnthropicProvider.js.map +1 -1
- package/dist/src/providers/anthropic/schemaConverter.d.ts +63 -0
- package/dist/src/providers/anthropic/schemaConverter.js +189 -0
- package/dist/src/providers/anthropic/schemaConverter.js.map +1 -0
- package/dist/src/providers/gemini/GeminiProvider.js +108 -11
- package/dist/src/providers/gemini/GeminiProvider.js.map +1 -1
- package/dist/src/providers/gemini/thoughtSignatures.d.ts +51 -0
- package/dist/src/providers/gemini/thoughtSignatures.js +189 -0
- package/dist/src/providers/gemini/thoughtSignatures.js.map +1 -0
- package/dist/src/providers/openai/OpenAIProvider.d.ts +78 -1
- package/dist/src/providers/openai/OpenAIProvider.js +1159 -190
- package/dist/src/providers/openai/OpenAIProvider.js.map +1 -1
- package/dist/src/providers/openai/ToolCallNormalizer.d.ts +6 -0
- package/dist/src/providers/openai/ToolCallNormalizer.js +16 -2
- package/dist/src/providers/openai/ToolCallNormalizer.js.map +1 -1
- package/dist/src/providers/openai/schemaConverter.d.ts +67 -0
- package/dist/src/providers/openai/schemaConverter.js +191 -0
- package/dist/src/providers/openai/schemaConverter.js.map +1 -0
- package/dist/src/providers/openai-responses/OpenAIResponsesProvider.d.ts +0 -4
- package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js +3 -75
- package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js.map +1 -1
- package/dist/src/providers/openai-responses/schemaConverter.d.ts +65 -0
- package/dist/src/providers/openai-responses/schemaConverter.js +195 -0
- package/dist/src/providers/openai-responses/schemaConverter.js.map +1 -0
- package/dist/src/providers/openai-vercel/OpenAIVercelProvider.d.ts +146 -0
- package/dist/src/providers/openai-vercel/OpenAIVercelProvider.js +1177 -0
- package/dist/src/providers/openai-vercel/OpenAIVercelProvider.js.map +1 -0
- package/dist/src/providers/openai-vercel/errors.d.ts +46 -0
- package/dist/src/providers/openai-vercel/errors.js +137 -0
- package/dist/src/providers/openai-vercel/errors.js.map +1 -0
- package/dist/src/providers/openai-vercel/index.d.ts +22 -0
- package/dist/src/providers/openai-vercel/index.js +23 -0
- package/dist/src/providers/openai-vercel/index.js.map +1 -0
- package/dist/src/providers/openai-vercel/messageConversion.d.ts +36 -0
- package/dist/src/providers/openai-vercel/messageConversion.js +410 -0
- package/dist/src/providers/openai-vercel/messageConversion.js.map +1 -0
- package/dist/src/providers/openai-vercel/schemaConverter.d.ts +66 -0
- package/dist/src/providers/openai-vercel/schemaConverter.js +191 -0
- package/dist/src/providers/openai-vercel/schemaConverter.js.map +1 -0
- package/dist/src/providers/openai-vercel/toolIdUtils.d.ts +33 -0
- package/dist/src/providers/openai-vercel/toolIdUtils.js +117 -0
- package/dist/src/providers/openai-vercel/toolIdUtils.js.map +1 -0
- package/dist/src/providers/reasoning/reasoningUtils.d.ts +43 -0
- package/dist/src/providers/reasoning/reasoningUtils.js +92 -0
- package/dist/src/providers/reasoning/reasoningUtils.js.map +1 -0
- package/dist/src/providers/utils/localEndpoint.js +6 -2
- package/dist/src/providers/utils/localEndpoint.js.map +1 -1
- package/dist/src/runtime/AgentRuntimeContext.d.ts +27 -0
- package/dist/src/runtime/AgentRuntimeContext.js.map +1 -1
- package/dist/src/runtime/createAgentRuntimeContext.js +27 -1
- package/dist/src/runtime/createAgentRuntimeContext.js.map +1 -1
- package/dist/src/services/history/IContent.d.ts +6 -0
- package/dist/src/services/history/IContent.js.map +1 -1
- package/dist/src/settings/types.d.ts +1 -1
- package/dist/src/tools/IToolFormatter.d.ts +1 -1
- package/dist/src/tools/ToolFormatter.js +14 -2
- package/dist/src/tools/ToolFormatter.js.map +1 -1
- package/dist/src/tools/ToolIdStrategy.d.ts +72 -0
- package/dist/src/tools/ToolIdStrategy.js +107 -0
- package/dist/src/tools/ToolIdStrategy.js.map +1 -0
- package/dist/src/tools/todo-schemas.d.ts +4 -4
- package/dist/src/utils/filesearch/ignore.js +3 -2
- package/dist/src/utils/filesearch/ignore.js.map +1 -1
- package/dist/src/utils/gitIgnoreParser.js +2 -1
- package/dist/src/utils/gitIgnoreParser.js.map +1 -1
- package/dist/src/utils/schemaValidator.js +41 -6
- package/dist/src/utils/schemaValidator.js.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,1177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2025 Vybestack LLC
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* @plan PLAN-20250218-STATELESSPROVIDER.P04
|
|
18
|
+
* @requirement REQ-SP-001
|
|
19
|
+
*
|
|
20
|
+
* OpenAI provider implemented on top of Vercel AI SDK v5, using the
|
|
21
|
+
* OpenAI chat completions API via @ai-sdk/openai + ai.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import * as Ai from 'ai';
|
|
25
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
26
|
+
import { isKimiModel } from '../../tools/ToolIdStrategy.js';
|
|
27
|
+
import { BaseProvider, } from '../BaseProvider.js';
|
|
28
|
+
import { DebugLogger } from '../../debug/index.js';
|
|
29
|
+
import { convertToolsToOpenAIVercel, } from './schemaConverter.js';
|
|
30
|
+
import { processToolParameters } from '../../tools/doubleEscapeUtils.js';
|
|
31
|
+
import { getCoreSystemPromptAsync } from '../../core/prompts.js';
|
|
32
|
+
import { resolveUserMemory } from '../utils/userMemory.js';
|
|
33
|
+
import { convertToVercelMessages } from './messageConversion.js';
|
|
34
|
+
import { getToolIdStrategy } from '../../tools/ToolIdStrategy.js';
|
|
35
|
+
import { resolveRuntimeAuthToken } from '../utils/authToken.js';
|
|
36
|
+
import { filterOpenAIRequestParams } from '../openai/openaiRequestParams.js';
|
|
37
|
+
import { isLocalEndpoint } from '../utils/localEndpoint.js';
|
|
38
|
+
import { AuthenticationError, wrapError } from './errors.js';
|
|
39
|
+
const streamText = Ai.streamText;
|
|
40
|
+
const generateText = Ai.generateText;
|
|
41
|
+
/**
|
|
42
|
+
* Vercel OpenAI-based provider using AI SDK v5.
|
|
43
|
+
*
|
|
44
|
+
* NOTE:
|
|
45
|
+
* - No dependency on the official `openai` SDK.
|
|
46
|
+
* - Uses `openai.chat(modelId)` to talk to the Chat Completions API.
|
|
47
|
+
* - Tools are configured via AI SDK `tool()` with JSON schema input.
|
|
48
|
+
*/
|
|
49
|
+
export class OpenAIVercelProvider extends BaseProvider {
|
|
50
|
+
getLogger() {
|
|
51
|
+
return new DebugLogger('llxprt:provider:openaivercel');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* @plan:PLAN-20251023-STATELESS-HARDENING.P08
|
|
55
|
+
* @requirement:REQ-SP4-003
|
|
56
|
+
* Constructor reduced to minimal initialization - no state captured.
|
|
57
|
+
*/
|
|
58
|
+
constructor(apiKey, baseURL, config, oauthManager) {
|
|
59
|
+
// Normalize empty string to undefined for proper precedence handling
|
|
60
|
+
const normalizedApiKey = apiKey && apiKey.trim() !== '' ? apiKey : undefined;
|
|
61
|
+
super({
|
|
62
|
+
name: 'openaivercel',
|
|
63
|
+
apiKey: normalizedApiKey,
|
|
64
|
+
baseURL,
|
|
65
|
+
envKeyNames: ['OPENAI_API_KEY'],
|
|
66
|
+
// AI SDK-based provider does not use OAuth directly here.
|
|
67
|
+
isOAuthEnabled: false,
|
|
68
|
+
oauthProvider: undefined,
|
|
69
|
+
oauthManager,
|
|
70
|
+
}, config);
|
|
71
|
+
}
|
|
72
|
+
supportsOAuth() {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Create an OpenAI provider instance for this call using AI SDK v5.
|
|
77
|
+
*
|
|
78
|
+
* Uses the resolved runtime auth token and baseURL, and still allows
|
|
79
|
+
* local endpoints without authentication (for Ollama-style servers).
|
|
80
|
+
*/
|
|
81
|
+
async createOpenAIClient(options) {
|
|
82
|
+
const authToken = (await resolveRuntimeAuthToken(options.resolved.authToken)) ?? '';
|
|
83
|
+
const baseURL = options.resolved.baseURL ?? this.baseProviderConfig.baseURL;
|
|
84
|
+
// Allow local endpoints without authentication
|
|
85
|
+
if (!authToken && !isLocalEndpoint(baseURL)) {
|
|
86
|
+
throw new AuthenticationError(`Auth token unavailable for runtimeId=${options.runtime?.runtimeId} (REQ-SP4-003).`, this.name);
|
|
87
|
+
}
|
|
88
|
+
const headers = this.getCustomHeaders();
|
|
89
|
+
return createOpenAI({
|
|
90
|
+
apiKey: authToken || undefined,
|
|
91
|
+
baseURL: baseURL || undefined,
|
|
92
|
+
headers: headers || undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Extract model parameters from normalized options instead of settings service.
|
|
97
|
+
* This mirrors OpenAIProvider but feeds AI SDK call options instead.
|
|
98
|
+
*/
|
|
99
|
+
extractModelParamsFromOptions(options) {
|
|
100
|
+
const providerSettings = options.settings?.getProviderSettings(this.name) ?? {};
|
|
101
|
+
const configEphemerals = options.invocation?.ephemerals ?? {};
|
|
102
|
+
const filteredProviderParams = filterOpenAIRequestParams(providerSettings);
|
|
103
|
+
const filteredEphemeralParams = filterOpenAIRequestParams(configEphemerals);
|
|
104
|
+
if (!filteredProviderParams && !filteredEphemeralParams) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
...(filteredProviderParams ?? {}),
|
|
109
|
+
...(filteredEphemeralParams ?? {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
getAiJsonSchema() {
|
|
113
|
+
try {
|
|
114
|
+
const candidate = Ai.jsonSchema;
|
|
115
|
+
return typeof candidate === 'function'
|
|
116
|
+
? candidate
|
|
117
|
+
: undefined;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
getAiTool() {
|
|
124
|
+
try {
|
|
125
|
+
const candidate = Ai.tool;
|
|
126
|
+
return typeof candidate === 'function'
|
|
127
|
+
? candidate
|
|
128
|
+
: undefined;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Normalize tool IDs from various formats to OpenAI-style format.
|
|
136
|
+
* Kept for compatibility with existing history/tool logic.
|
|
137
|
+
*/
|
|
138
|
+
normalizeToOpenAIToolId(id) {
|
|
139
|
+
const sanitize = (value) => value.replace(/[^a-zA-Z0-9_]/g, '') ||
|
|
140
|
+
'call_' + crypto.randomUUID().replace(/-/g, '');
|
|
141
|
+
// If already in OpenAI format, return as-is
|
|
142
|
+
if (id.startsWith('call_')) {
|
|
143
|
+
return sanitize(id);
|
|
144
|
+
}
|
|
145
|
+
// For history format, extract the UUID and add OpenAI prefix
|
|
146
|
+
if (id.startsWith('hist_tool_')) {
|
|
147
|
+
const uuid = id.substring('hist_tool_'.length);
|
|
148
|
+
return sanitize('call_' + uuid);
|
|
149
|
+
}
|
|
150
|
+
// For Anthropic format, extract the UUID and add OpenAI prefix
|
|
151
|
+
if (id.startsWith('toolu_')) {
|
|
152
|
+
const uuid = id.substring('toolu_'.length);
|
|
153
|
+
return sanitize('call_' + uuid);
|
|
154
|
+
}
|
|
155
|
+
// Unknown format - assume it's a raw UUID
|
|
156
|
+
return sanitize('call_' + id);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Normalize tool IDs from OpenAI-style format to history format.
|
|
160
|
+
*/
|
|
161
|
+
normalizeToHistoryToolId(id) {
|
|
162
|
+
// If already in history format, return as-is
|
|
163
|
+
if (id.startsWith('hist_tool_')) {
|
|
164
|
+
return id;
|
|
165
|
+
}
|
|
166
|
+
// For OpenAI format, extract the UUID and add history prefix
|
|
167
|
+
if (id.startsWith('call_')) {
|
|
168
|
+
const uuid = id.substring('call_'.length);
|
|
169
|
+
return 'hist_tool_' + uuid;
|
|
170
|
+
}
|
|
171
|
+
// For Anthropic format, extract the UUID and add history prefix
|
|
172
|
+
if (id.startsWith('toolu_')) {
|
|
173
|
+
const uuid = id.substring('toolu_'.length);
|
|
174
|
+
return 'hist_tool_' + uuid;
|
|
175
|
+
}
|
|
176
|
+
// Unknown format - assume it's a raw UUID
|
|
177
|
+
return 'hist_tool_' + id;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Convert internal history IContent[] to AI SDK ModelMessage[].
|
|
181
|
+
*
|
|
182
|
+
* This implementation uses textual tool replay for past tool calls/results.
|
|
183
|
+
* New tool calls in the current response still use structured ToolCallBlocks.
|
|
184
|
+
*
|
|
185
|
+
* For Kimi K2 models, uses ToolIdStrategy to generate proper tool IDs
|
|
186
|
+
* in the format functions.{name}:{index} instead of call_xxx.
|
|
187
|
+
*/
|
|
188
|
+
convertToModelMessages(contents) {
|
|
189
|
+
const toolFormat = this.detectToolFormat();
|
|
190
|
+
// Create a ToolIdMapper based on the tool format
|
|
191
|
+
// For Kimi K2, this generates sequential IDs in the format functions.{name}:{index}
|
|
192
|
+
const toolIdMapper = toolFormat === 'kimi'
|
|
193
|
+
? getToolIdStrategy('kimi').createMapper(contents)
|
|
194
|
+
: undefined;
|
|
195
|
+
return convertToVercelMessages(contents, toolIdMapper);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build an AI SDK ToolSet from already-normalized OpenAI-style tool definitions.
|
|
199
|
+
*
|
|
200
|
+
* Input is the array produced by convertToolsToOpenAIVercel().
|
|
201
|
+
*/
|
|
202
|
+
buildVercelTools(formattedTools) {
|
|
203
|
+
if (!formattedTools || formattedTools.length === 0) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const jsonSchemaFn = this.getAiJsonSchema() ??
|
|
207
|
+
((schema) => schema);
|
|
208
|
+
const toolFn = this.getAiTool() ??
|
|
209
|
+
((config) => config);
|
|
210
|
+
const toolsRecord = {};
|
|
211
|
+
for (const t of formattedTools) {
|
|
212
|
+
if (!t || t.type !== 'function')
|
|
213
|
+
continue;
|
|
214
|
+
const fn = t.function;
|
|
215
|
+
if (!fn?.name)
|
|
216
|
+
continue;
|
|
217
|
+
if (toolsRecord[fn.name])
|
|
218
|
+
continue;
|
|
219
|
+
const inputSchema = fn.parameters
|
|
220
|
+
? jsonSchemaFn(fn.parameters)
|
|
221
|
+
: jsonSchemaFn({
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {},
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
});
|
|
226
|
+
toolsRecord[fn.name] = toolFn({
|
|
227
|
+
description: fn.description,
|
|
228
|
+
inputSchema,
|
|
229
|
+
// No execute() – we only surface tool calls back to the caller,
|
|
230
|
+
// execution is handled by the existing external tool pipeline.
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return Object.keys(toolsRecord).length > 0 ? toolsRecord : undefined;
|
|
234
|
+
}
|
|
235
|
+
mapUsageToMetadata(usage) {
|
|
236
|
+
if (!usage)
|
|
237
|
+
return undefined;
|
|
238
|
+
const promptTokens = usage.inputTokens ??
|
|
239
|
+
usage.promptTokens ??
|
|
240
|
+
0;
|
|
241
|
+
const completionTokens = usage.outputTokens ??
|
|
242
|
+
usage.completionTokens ??
|
|
243
|
+
0;
|
|
244
|
+
const totalTokens = usage.totalTokens ??
|
|
245
|
+
(typeof promptTokens === 'number' && typeof completionTokens === 'number'
|
|
246
|
+
? promptTokens + completionTokens
|
|
247
|
+
: 0);
|
|
248
|
+
return {
|
|
249
|
+
promptTokens,
|
|
250
|
+
completionTokens,
|
|
251
|
+
totalTokens,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Extract thinking content from <think>, <thinking>, or <analysis> tags
|
|
256
|
+
* and return it as a ThinkingBlock. Returns null if no thinking tags found.
|
|
257
|
+
*
|
|
258
|
+
* This must be called BEFORE sanitizeText which strips these tags.
|
|
259
|
+
*
|
|
260
|
+
* Handles two formats:
|
|
261
|
+
* 1. Standard: <think>Full thinking paragraph here...</think>
|
|
262
|
+
* 2. Fragmented (Synthetic API): <think>word</think><think>word</think>...
|
|
263
|
+
*
|
|
264
|
+
* For fragmented format, joins with spaces. For standard, joins with newlines.
|
|
265
|
+
*/
|
|
266
|
+
extractThinkTagsAsBlock(text) {
|
|
267
|
+
if (!text) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const thinkingParts = [];
|
|
271
|
+
// Match <think>...</think>
|
|
272
|
+
const thinkMatches = text.matchAll(/<think>([\s\S]*?)<\/think>/gi);
|
|
273
|
+
for (const match of thinkMatches) {
|
|
274
|
+
if (match[1]?.trim()) {
|
|
275
|
+
thinkingParts.push(match[1].trim());
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Match <thinking>...</thinking>
|
|
279
|
+
const thinkingMatches = text.matchAll(/<thinking>([\s\S]*?)<\/thinking>/gi);
|
|
280
|
+
for (const match of thinkingMatches) {
|
|
281
|
+
if (match[1]?.trim()) {
|
|
282
|
+
thinkingParts.push(match[1].trim());
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Match <analysis>...</analysis>
|
|
286
|
+
const analysisMatches = text.matchAll(/<analysis>([\s\S]*?)<\/analysis>/gi);
|
|
287
|
+
for (const match of analysisMatches) {
|
|
288
|
+
if (match[1]?.trim()) {
|
|
289
|
+
thinkingParts.push(match[1].trim());
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (thinkingParts.length === 0) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
// Detect fragmented format: many short parts (likely token-by-token streaming)
|
|
296
|
+
const avgPartLength = thinkingParts.reduce((sum, p) => sum + p.length, 0) /
|
|
297
|
+
thinkingParts.length;
|
|
298
|
+
const isFragmented = thinkingParts.length > 5 && avgPartLength < 15;
|
|
299
|
+
// Join with space for fragmented, newlines for standard multi-paragraph thinking
|
|
300
|
+
const combinedThought = isFragmented
|
|
301
|
+
? thinkingParts.join(' ')
|
|
302
|
+
: thinkingParts.join('\n\n');
|
|
303
|
+
const logger = this.getLogger();
|
|
304
|
+
logger.debug(() => `[OpenAIVercelProvider] Extracted thinking from tags: ${combinedThought.length} chars`, { tagCount: thinkingParts.length, isFragmented, avgPartLength });
|
|
305
|
+
return {
|
|
306
|
+
type: 'thinking',
|
|
307
|
+
thought: combinedThought,
|
|
308
|
+
sourceField: 'think_tags',
|
|
309
|
+
isHidden: false,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Sanitize text content from provider response by removing thinking tags and artifacts.
|
|
314
|
+
* This prevents <think>...</think> tags from leaking into visible output.
|
|
315
|
+
*/
|
|
316
|
+
sanitizeText(text) {
|
|
317
|
+
if (!text) {
|
|
318
|
+
return text;
|
|
319
|
+
}
|
|
320
|
+
// Check if there are any reasoning tags before modification
|
|
321
|
+
const hadReasoningTags = /<(?:think|thinking|analysis)>|<\/(?:think|thinking|analysis)>/i.test(text);
|
|
322
|
+
let cleaned = text;
|
|
323
|
+
// Remove <think>...</think> tags and their content
|
|
324
|
+
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/gi, '\n');
|
|
325
|
+
// Remove <thinking>...</thinking> tags and their content
|
|
326
|
+
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '\n');
|
|
327
|
+
// Remove <analysis>...</analysis> tags and their content
|
|
328
|
+
cleaned = cleaned.replace(/<analysis>[\s\S]*?<\/analysis>/gi, '\n');
|
|
329
|
+
// Remove unclosed tags (streaming edge case)
|
|
330
|
+
cleaned = cleaned.replace(/<think>[\s\S]*$/gi, '');
|
|
331
|
+
cleaned = cleaned.replace(/<thinking>[\s\S]*$/gi, '');
|
|
332
|
+
cleaned = cleaned.replace(/<analysis>[\s\S]*$/gi, '');
|
|
333
|
+
// Also remove opening tags without closing (another streaming edge case)
|
|
334
|
+
cleaned = cleaned.replace(/<think>/gi, '');
|
|
335
|
+
cleaned = cleaned.replace(/<thinking>/gi, '');
|
|
336
|
+
cleaned = cleaned.replace(/<analysis>/gi, '');
|
|
337
|
+
// Only clean up whitespace if we had reasoning tags to strip
|
|
338
|
+
// This preserves meaningful whitespace in regular text chunks during streaming
|
|
339
|
+
// (e.g., " 5 Biggest" should remain " 5 Biggest", not become "5 Biggest")
|
|
340
|
+
if (hadReasoningTags) {
|
|
341
|
+
// Normalize multiple consecutive newlines to at most two
|
|
342
|
+
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
|
343
|
+
// Trim leading/trailing whitespace only when we stripped tags
|
|
344
|
+
cleaned = cleaned.trim();
|
|
345
|
+
}
|
|
346
|
+
return cleaned;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Get a short preview of a message's content for debug logging.
|
|
350
|
+
*/
|
|
351
|
+
getContentPreview(content, maxLength = 200) {
|
|
352
|
+
if (content === null || content === undefined) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
if (typeof content === 'string') {
|
|
356
|
+
if (content.length <= maxLength) {
|
|
357
|
+
return content;
|
|
358
|
+
}
|
|
359
|
+
return `${content.slice(0, maxLength)}…`;
|
|
360
|
+
}
|
|
361
|
+
if (Array.isArray(content)) {
|
|
362
|
+
// text parts, tool-call parts, etc.
|
|
363
|
+
const textParts = content.map((part) => {
|
|
364
|
+
if (typeof part === 'object' &&
|
|
365
|
+
part !== null &&
|
|
366
|
+
'type' in part &&
|
|
367
|
+
part.type === 'text') {
|
|
368
|
+
return part.text ?? '';
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
return JSON.stringify(part);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return '[unserializable part]';
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
const joined = textParts.join('\n');
|
|
378
|
+
if (joined.length <= maxLength) {
|
|
379
|
+
return joined;
|
|
380
|
+
}
|
|
381
|
+
return `${joined.slice(0, maxLength)}…`;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const serialized = JSON.stringify(content);
|
|
385
|
+
if (serialized.length <= maxLength) {
|
|
386
|
+
return serialized;
|
|
387
|
+
}
|
|
388
|
+
return `${serialized.slice(0, maxLength)}…`;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return '[unserializable content]';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Core chat completion implementation using AI SDK v5.
|
|
396
|
+
*
|
|
397
|
+
* This replaces the original OpenAI SDK v5 client usage with:
|
|
398
|
+
* - createOpenAI({ apiKey, baseURL })
|
|
399
|
+
* - openai.chat(modelId)
|
|
400
|
+
* - generateText / streamText
|
|
401
|
+
*/
|
|
402
|
+
async *generateChatCompletionWithOptions(options) {
|
|
403
|
+
const logger = this.getLogger();
|
|
404
|
+
const { contents, tools, metadata } = options;
|
|
405
|
+
const modelId = options.resolved.model || this.getDefaultModel();
|
|
406
|
+
const abortSignal = metadata?.abortSignal;
|
|
407
|
+
const ephemerals = options.invocation?.ephemerals ?? {};
|
|
408
|
+
const resolved = options.resolved;
|
|
409
|
+
if (logger.enabled) {
|
|
410
|
+
logger.debug(() => `[OpenAIVercelProvider] Resolved request context`, {
|
|
411
|
+
provider: this.name,
|
|
412
|
+
model: modelId,
|
|
413
|
+
resolvedModel: resolved.model,
|
|
414
|
+
resolvedBaseUrl: resolved.baseURL,
|
|
415
|
+
authTokenPresent: Boolean(resolved.authToken),
|
|
416
|
+
messageCount: contents.length,
|
|
417
|
+
toolCount: tools?.length ?? 0,
|
|
418
|
+
metadataKeys: Object.keys(metadata ?? {}),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// Determine streaming vs non-streaming mode (default: enabled)
|
|
422
|
+
const streamingSetting = ephemerals['streaming'];
|
|
423
|
+
const streamingResolved = options.resolved?.streaming;
|
|
424
|
+
const streamingEnabled = streamingResolved === false
|
|
425
|
+
? false
|
|
426
|
+
: streamingResolved === true
|
|
427
|
+
? true
|
|
428
|
+
: streamingSetting !== 'disabled';
|
|
429
|
+
// System prompt (same core-prompt mechanism as OpenAIProvider)
|
|
430
|
+
const flattenedToolNames = tools?.flatMap((group) => group.functionDeclarations
|
|
431
|
+
.map((decl) => decl.name)
|
|
432
|
+
.filter((name) => !!name)) ?? [];
|
|
433
|
+
const toolNamesArg = tools === undefined ? undefined : Array.from(new Set(flattenedToolNames));
|
|
434
|
+
const userMemory = await resolveUserMemory(options.userMemory, () => options.invocation?.userMemory);
|
|
435
|
+
const systemPrompt = await getCoreSystemPromptAsync(userMemory, modelId, toolNamesArg);
|
|
436
|
+
// Convert internal history to AI SDK ModelMessages with structured tool replay.
|
|
437
|
+
const messages = this.convertToModelMessages(contents);
|
|
438
|
+
if (logger.enabled) {
|
|
439
|
+
logger.debug(() => `[OpenAIVercelProvider] Chat payload snapshot`, {
|
|
440
|
+
messageCount: messages.length,
|
|
441
|
+
messages: messages.map((msg) => ({
|
|
442
|
+
role: msg.role,
|
|
443
|
+
contentPreview: this.getContentPreview(msg.content),
|
|
444
|
+
})),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
// Convert Gemini tools to OpenAI-style definitions using provider-specific converter
|
|
448
|
+
const formattedTools = convertToolsToOpenAIVercel(tools);
|
|
449
|
+
if (logger.enabled && formattedTools) {
|
|
450
|
+
logger.debug(() => `[OpenAIVercelProvider] Tool conversion summary`, {
|
|
451
|
+
hasTools: !!formattedTools,
|
|
452
|
+
toolCount: formattedTools.length,
|
|
453
|
+
toolNames: formattedTools.map((t) => t.function.name),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
// Build AI SDK ToolSet
|
|
457
|
+
const aiTools = this.buildVercelTools(formattedTools);
|
|
458
|
+
// Model parameters (temperature, top_p, etc.)
|
|
459
|
+
const modelParams = this.extractModelParamsFromOptions(options) ?? {};
|
|
460
|
+
const maxTokensMeta = metadata?.maxTokens ??
|
|
461
|
+
ephemerals['max-tokens'];
|
|
462
|
+
const maxTokensOverride = modelParams['max_tokens'] ?? undefined;
|
|
463
|
+
const maxOutputTokens = typeof maxTokensMeta === 'number' && Number.isFinite(maxTokensMeta)
|
|
464
|
+
? maxTokensMeta
|
|
465
|
+
: typeof maxTokensOverride === 'number' &&
|
|
466
|
+
Number.isFinite(maxTokensOverride)
|
|
467
|
+
? maxTokensOverride
|
|
468
|
+
: undefined;
|
|
469
|
+
const temperature = modelParams['temperature'];
|
|
470
|
+
const topP = modelParams['top_p'];
|
|
471
|
+
const presencePenalty = modelParams['presence_penalty'];
|
|
472
|
+
const frequencyPenalty = modelParams['frequency_penalty'];
|
|
473
|
+
const stopSetting = modelParams['stop'];
|
|
474
|
+
const stopSequences = typeof stopSetting === 'string'
|
|
475
|
+
? [stopSetting]
|
|
476
|
+
: Array.isArray(stopSetting)
|
|
477
|
+
? stopSetting
|
|
478
|
+
: undefined;
|
|
479
|
+
const seed = modelParams['seed'];
|
|
480
|
+
const maxRetries = ephemerals['retries'] ?? 2; // AI SDK default is 2
|
|
481
|
+
// Instantiate AI SDK OpenAI provider + chat model
|
|
482
|
+
const openaiProvider = await this.createOpenAIClient(options);
|
|
483
|
+
const providerWithChat = openaiProvider;
|
|
484
|
+
const model = (providerWithChat.chat
|
|
485
|
+
? providerWithChat.chat(modelId)
|
|
486
|
+
: providerWithChat(modelId));
|
|
487
|
+
if (logger.enabled) {
|
|
488
|
+
logger.debug(() => `[OpenAIVercelProvider] Sending chat request`, {
|
|
489
|
+
model: modelId,
|
|
490
|
+
baseURL: resolved.baseURL ?? this.getBaseURL(),
|
|
491
|
+
streamingEnabled,
|
|
492
|
+
hasTools: !!aiTools,
|
|
493
|
+
toolCount: aiTools ? Object.keys(aiTools).length : 0,
|
|
494
|
+
maxOutputTokens,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (streamingEnabled) {
|
|
498
|
+
// Streaming mode via streamText()
|
|
499
|
+
const streamOptions = {
|
|
500
|
+
model,
|
|
501
|
+
system: systemPrompt,
|
|
502
|
+
messages,
|
|
503
|
+
tools: aiTools,
|
|
504
|
+
maxOutputTokens,
|
|
505
|
+
temperature,
|
|
506
|
+
topP,
|
|
507
|
+
presencePenalty,
|
|
508
|
+
frequencyPenalty,
|
|
509
|
+
stopSequences,
|
|
510
|
+
seed,
|
|
511
|
+
maxRetries,
|
|
512
|
+
abortSignal,
|
|
513
|
+
};
|
|
514
|
+
if (maxOutputTokens !== undefined) {
|
|
515
|
+
streamOptions['maxTokens'] = maxOutputTokens;
|
|
516
|
+
}
|
|
517
|
+
let result;
|
|
518
|
+
try {
|
|
519
|
+
result = await streamText(streamOptions);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
logger.error(() => `[OpenAIVercelProvider] streamText failed: ${error instanceof Error ? error.message : String(error)}`, { error });
|
|
523
|
+
throw wrapError(error, this.name);
|
|
524
|
+
}
|
|
525
|
+
const collectedToolCalls = [];
|
|
526
|
+
let totalUsage;
|
|
527
|
+
let finishReason;
|
|
528
|
+
const hasFullStream = result &&
|
|
529
|
+
typeof result === 'object' &&
|
|
530
|
+
'fullStream' in result;
|
|
531
|
+
// Buffer for accumulating text chunks for <think> tag processing
|
|
532
|
+
let textBuffer = '';
|
|
533
|
+
let accumulatedThinkingContent = '';
|
|
534
|
+
let hasEmittedThinking = false;
|
|
535
|
+
// Capture method references for use in nested functions
|
|
536
|
+
const extractThinkTags = this.extractThinkTagsAsBlock.bind(this);
|
|
537
|
+
const sanitizeTextFn = this.sanitizeText.bind(this);
|
|
538
|
+
// Helper to check if buffer has an open think tag without closing
|
|
539
|
+
const hasOpenThinkTag = (text) => {
|
|
540
|
+
const openCount = (text.match(/<think>/gi) ?? []).length;
|
|
541
|
+
const closeCount = (text.match(/<\/think>/gi) ?? []).length;
|
|
542
|
+
return openCount > closeCount;
|
|
543
|
+
};
|
|
544
|
+
// Helper to flush buffered text, extracting thinking and sanitizing.
|
|
545
|
+
// Note: This generator intentionally captures and mutates outer scope variables
|
|
546
|
+
// (accumulatedThinkingContent, hasEmittedThinking) via closure. This is by design
|
|
547
|
+
// to maintain state across multiple flush calls during streaming, allowing thinking
|
|
548
|
+
// content to be accumulated across chunks and emitted as a single block.
|
|
549
|
+
const flushBuffer = function* (buffer, isEnd) {
|
|
550
|
+
if (!buffer)
|
|
551
|
+
return '';
|
|
552
|
+
// Don't flush if we have unclosed think tags (unless this is the end)
|
|
553
|
+
if (!isEnd && hasOpenThinkTag(buffer)) {
|
|
554
|
+
return buffer;
|
|
555
|
+
}
|
|
556
|
+
// Extract thinking tags and accumulate
|
|
557
|
+
const thinkBlock = extractThinkTags(buffer);
|
|
558
|
+
if (thinkBlock) {
|
|
559
|
+
if (accumulatedThinkingContent.length > 0) {
|
|
560
|
+
accumulatedThinkingContent += ' ';
|
|
561
|
+
}
|
|
562
|
+
accumulatedThinkingContent += thinkBlock.thought;
|
|
563
|
+
logger.debug(() => `[OpenAIVercelProvider] Accumulated thinking: ${accumulatedThinkingContent.length} chars`);
|
|
564
|
+
}
|
|
565
|
+
// Emit accumulated thinking block before other content
|
|
566
|
+
if (!hasEmittedThinking &&
|
|
567
|
+
accumulatedThinkingContent.length > 0 &&
|
|
568
|
+
(isEnd || buffer.includes('</think>'))) {
|
|
569
|
+
yield {
|
|
570
|
+
speaker: 'ai',
|
|
571
|
+
blocks: [
|
|
572
|
+
{
|
|
573
|
+
type: 'thinking',
|
|
574
|
+
thought: accumulatedThinkingContent,
|
|
575
|
+
sourceField: 'think_tags',
|
|
576
|
+
isHidden: false,
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
};
|
|
580
|
+
hasEmittedThinking = true;
|
|
581
|
+
logger.debug(() => `[OpenAIVercelProvider] Emitted thinking block: ${accumulatedThinkingContent.length} chars`);
|
|
582
|
+
}
|
|
583
|
+
// Sanitize and yield visible text
|
|
584
|
+
const sanitizedText = sanitizeTextFn(buffer);
|
|
585
|
+
if (sanitizedText) {
|
|
586
|
+
yield {
|
|
587
|
+
speaker: 'ai',
|
|
588
|
+
blocks: [
|
|
589
|
+
{
|
|
590
|
+
type: 'text',
|
|
591
|
+
text: sanitizedText,
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return '';
|
|
597
|
+
};
|
|
598
|
+
if (hasFullStream && result.fullStream) {
|
|
599
|
+
try {
|
|
600
|
+
for await (const part of result.fullStream) {
|
|
601
|
+
if (abortSignal?.aborted) {
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
switch (part.type) {
|
|
605
|
+
case 'text-delta': {
|
|
606
|
+
const text = typeof part.text === 'string' ? part.text : '';
|
|
607
|
+
if (text) {
|
|
608
|
+
// Check if this chunk or buffer contains think tags
|
|
609
|
+
const hasThinkContent = text.includes('<think') ||
|
|
610
|
+
text.includes('</think') ||
|
|
611
|
+
textBuffer.includes('<think');
|
|
612
|
+
if (hasThinkContent) {
|
|
613
|
+
// Buffer mode: accumulate text for think tag processing
|
|
614
|
+
textBuffer += text;
|
|
615
|
+
// Flush buffer at natural break points if no open think tags
|
|
616
|
+
if (!hasOpenThinkTag(textBuffer) &&
|
|
617
|
+
(textBuffer.includes('\n') ||
|
|
618
|
+
textBuffer.endsWith('. ') ||
|
|
619
|
+
textBuffer.endsWith('! ') ||
|
|
620
|
+
textBuffer.endsWith('? ') ||
|
|
621
|
+
textBuffer.length > 100)) {
|
|
622
|
+
for (const content of flushBuffer(textBuffer, false)) {
|
|
623
|
+
yield content;
|
|
624
|
+
}
|
|
625
|
+
textBuffer = '';
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
// Direct streaming mode: no think tags, stream text directly
|
|
630
|
+
yield {
|
|
631
|
+
speaker: 'ai',
|
|
632
|
+
blocks: [
|
|
633
|
+
{
|
|
634
|
+
type: 'text',
|
|
635
|
+
text,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
case 'tool-call': {
|
|
644
|
+
// Single completed tool call with already-parsed input
|
|
645
|
+
if (part.toolCallId && part.toolName) {
|
|
646
|
+
collectedToolCalls.push({
|
|
647
|
+
toolCallId: String(part.toolCallId),
|
|
648
|
+
toolName: String(part.toolName),
|
|
649
|
+
input: part.input,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
case 'finish': {
|
|
655
|
+
totalUsage = part.totalUsage;
|
|
656
|
+
finishReason = part.finishReason;
|
|
657
|
+
// Flush any remaining buffer on finish
|
|
658
|
+
if (textBuffer) {
|
|
659
|
+
for (const content of flushBuffer(textBuffer, true)) {
|
|
660
|
+
yield content;
|
|
661
|
+
}
|
|
662
|
+
textBuffer = '';
|
|
663
|
+
}
|
|
664
|
+
if (logger.enabled) {
|
|
665
|
+
logger.debug(() => `[OpenAIVercelProvider] streamText finished with reason: ${part.finishReason}`, {
|
|
666
|
+
finishReason: part.finishReason,
|
|
667
|
+
hasUsage: !!totalUsage,
|
|
668
|
+
toolCallCount: collectedToolCalls.length,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
case 'error': {
|
|
674
|
+
throw part.error ?? new Error('Streaming error from AI SDK');
|
|
675
|
+
}
|
|
676
|
+
case 'reasoning': {
|
|
677
|
+
// Handle reasoning/thinking content from models like Kimi K2
|
|
678
|
+
// Accumulate reasoning content rather than emitting immediately
|
|
679
|
+
// This allows combining with <think> tags from text-delta
|
|
680
|
+
const reasoning = part.text;
|
|
681
|
+
if (reasoning) {
|
|
682
|
+
if (accumulatedThinkingContent.length > 0) {
|
|
683
|
+
accumulatedThinkingContent += ' ';
|
|
684
|
+
}
|
|
685
|
+
accumulatedThinkingContent += reasoning;
|
|
686
|
+
logger.debug(() => `[OpenAIVercelProvider] Accumulated reasoning: ${accumulatedThinkingContent.length} chars`);
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
default:
|
|
691
|
+
// Ignore other parts: source, start-step, finish-step, etc.
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Final buffer flush if not caught by finish event (e.g., aborted early)
|
|
696
|
+
if (textBuffer) {
|
|
697
|
+
for (const content of flushBuffer(textBuffer, true)) {
|
|
698
|
+
yield content;
|
|
699
|
+
}
|
|
700
|
+
textBuffer = '';
|
|
701
|
+
}
|
|
702
|
+
// Emit any remaining accumulated thinking content that wasn't emitted yet
|
|
703
|
+
if (!hasEmittedThinking && accumulatedThinkingContent.length > 0) {
|
|
704
|
+
yield {
|
|
705
|
+
speaker: 'ai',
|
|
706
|
+
blocks: [
|
|
707
|
+
{
|
|
708
|
+
type: 'thinking',
|
|
709
|
+
thought: accumulatedThinkingContent,
|
|
710
|
+
sourceField: 'reasoning_content',
|
|
711
|
+
isHidden: false,
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
};
|
|
715
|
+
hasEmittedThinking = true;
|
|
716
|
+
logger.debug(() => `[OpenAIVercelProvider] Emitted final thinking block: ${accumulatedThinkingContent.length} chars`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
if (abortSignal?.aborted ||
|
|
721
|
+
(error &&
|
|
722
|
+
typeof error === 'object' &&
|
|
723
|
+
'name' in error &&
|
|
724
|
+
error.name === 'AbortError')) {
|
|
725
|
+
logger.debug(() => `[OpenAIVercelProvider] Streaming response cancelled by AbortSignal`);
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
logger.error(() => `[OpenAIVercelProvider] Error processing streaming response: ${error instanceof Error ? error.message : String(error)}`, { error });
|
|
729
|
+
throw wrapError(error, this.name);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
const legacyStream = result;
|
|
734
|
+
try {
|
|
735
|
+
if (legacyStream.textStream) {
|
|
736
|
+
for await (const textChunk of legacyStream.textStream) {
|
|
737
|
+
if (!textChunk) {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
yield {
|
|
741
|
+
speaker: 'ai',
|
|
742
|
+
blocks: [
|
|
743
|
+
{
|
|
744
|
+
type: 'text',
|
|
745
|
+
text: textChunk,
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (abortSignal?.aborted ||
|
|
754
|
+
(error &&
|
|
755
|
+
typeof error === 'object' &&
|
|
756
|
+
'name' in error &&
|
|
757
|
+
error.name === 'AbortError')) {
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
logger.error(() => `[OpenAIVercelProvider] Legacy streaming response failed: ${error instanceof Error ? error.message : String(error)}`, { error });
|
|
761
|
+
throw wrapError(error, this.name);
|
|
762
|
+
}
|
|
763
|
+
const legacyToolCalls = (legacyStream.toolCalls
|
|
764
|
+
? await legacyStream.toolCalls.catch(() => [])
|
|
765
|
+
: []) ?? [];
|
|
766
|
+
for (const call of legacyToolCalls) {
|
|
767
|
+
collectedToolCalls.push({
|
|
768
|
+
toolCallId: String(call.toolCallId ?? crypto.randomUUID()),
|
|
769
|
+
toolName: String(call.toolName ?? 'unknown_tool'),
|
|
770
|
+
input: call.input,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
totalUsage = legacyStream.usage
|
|
774
|
+
? await legacyStream.usage.catch(() => undefined)
|
|
775
|
+
: undefined;
|
|
776
|
+
finishReason = legacyStream.finishReason
|
|
777
|
+
? await legacyStream.finishReason.catch(() => undefined)
|
|
778
|
+
: undefined;
|
|
779
|
+
}
|
|
780
|
+
// Emit accumulated tool calls as a single IContent, with usage metadata if available
|
|
781
|
+
if (collectedToolCalls.length > 0) {
|
|
782
|
+
const blocks = collectedToolCalls.map((call) => {
|
|
783
|
+
let argsString = '{}';
|
|
784
|
+
try {
|
|
785
|
+
argsString =
|
|
786
|
+
typeof call.input === 'string'
|
|
787
|
+
? call.input
|
|
788
|
+
: JSON.stringify(call.input ?? {});
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
argsString = '{}';
|
|
792
|
+
}
|
|
793
|
+
const processedParameters = processToolParameters(argsString, call.toolName);
|
|
794
|
+
return {
|
|
795
|
+
type: 'tool_call',
|
|
796
|
+
id: this.normalizeToHistoryToolId(this.normalizeToOpenAIToolId(call.toolCallId)),
|
|
797
|
+
name: call.toolName,
|
|
798
|
+
parameters: processedParameters,
|
|
799
|
+
};
|
|
800
|
+
});
|
|
801
|
+
const usageMeta = this.mapUsageToMetadata(totalUsage);
|
|
802
|
+
const metadata = usageMeta || finishReason
|
|
803
|
+
? {
|
|
804
|
+
...(usageMeta ? { usage: usageMeta } : {}),
|
|
805
|
+
...(finishReason ? { finishReason } : {}),
|
|
806
|
+
}
|
|
807
|
+
: undefined;
|
|
808
|
+
const toolContent = {
|
|
809
|
+
speaker: 'ai',
|
|
810
|
+
blocks,
|
|
811
|
+
...(metadata ? { metadata } : {}),
|
|
812
|
+
};
|
|
813
|
+
yield toolContent;
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
// Emit metadata-only message so callers can see usage/finish reason
|
|
817
|
+
const usageMeta = this.mapUsageToMetadata(totalUsage);
|
|
818
|
+
const metadata = usageMeta || finishReason
|
|
819
|
+
? {
|
|
820
|
+
...(usageMeta ? { usage: usageMeta } : {}),
|
|
821
|
+
...(finishReason ? { finishReason } : {}),
|
|
822
|
+
}
|
|
823
|
+
: undefined;
|
|
824
|
+
if (metadata) {
|
|
825
|
+
yield {
|
|
826
|
+
speaker: 'ai',
|
|
827
|
+
blocks: [],
|
|
828
|
+
metadata,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
// Non-streaming mode via generateText()
|
|
835
|
+
let result;
|
|
836
|
+
try {
|
|
837
|
+
const aiToolFn = this.getAiTool();
|
|
838
|
+
const toolsForGenerate = (!aiToolFn && formattedTools ? formattedTools : aiTools) ?? undefined;
|
|
839
|
+
const generateOptions = {
|
|
840
|
+
model,
|
|
841
|
+
system: systemPrompt,
|
|
842
|
+
messages,
|
|
843
|
+
tools: toolsForGenerate,
|
|
844
|
+
maxOutputTokens,
|
|
845
|
+
temperature,
|
|
846
|
+
topP,
|
|
847
|
+
presencePenalty,
|
|
848
|
+
frequencyPenalty,
|
|
849
|
+
stopSequences,
|
|
850
|
+
seed,
|
|
851
|
+
maxRetries,
|
|
852
|
+
abortSignal,
|
|
853
|
+
};
|
|
854
|
+
if (maxOutputTokens !== undefined) {
|
|
855
|
+
generateOptions['maxTokens'] = maxOutputTokens;
|
|
856
|
+
}
|
|
857
|
+
result = await generateText(generateOptions);
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
logger.error(() => `[OpenAIVercelProvider] Non-streaming chat completion failed: ${error instanceof Error ? error.message : String(error)}`, { error });
|
|
861
|
+
throw wrapError(error, this.name);
|
|
862
|
+
}
|
|
863
|
+
const blocks = [];
|
|
864
|
+
if (result.text) {
|
|
865
|
+
blocks.push({
|
|
866
|
+
type: 'text',
|
|
867
|
+
text: result.text,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
// Typed tool calls from AI SDK; execution is not automatic because we did not provide execute().
|
|
871
|
+
const toolCalls = 'toolCalls' in result && result.toolCalls ? await result.toolCalls : [];
|
|
872
|
+
for (const call of toolCalls) {
|
|
873
|
+
const toolName = call.toolName ?? 'unknown_tool';
|
|
874
|
+
const id = call.toolCallId ?? crypto.randomUUID();
|
|
875
|
+
const rawInput = call.input ??
|
|
876
|
+
call.args ??
|
|
877
|
+
call.arguments;
|
|
878
|
+
let argsString = '{}';
|
|
879
|
+
try {
|
|
880
|
+
argsString =
|
|
881
|
+
typeof rawInput === 'string'
|
|
882
|
+
? rawInput
|
|
883
|
+
: JSON.stringify(rawInput ?? {});
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
argsString = '{}';
|
|
887
|
+
}
|
|
888
|
+
const processedParameters = processToolParameters(argsString, toolName);
|
|
889
|
+
blocks.push({
|
|
890
|
+
type: 'tool_call',
|
|
891
|
+
id: this.normalizeToHistoryToolId(this.normalizeToOpenAIToolId(id)),
|
|
892
|
+
name: toolName,
|
|
893
|
+
parameters: processedParameters,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
if (blocks.length > 0 || result.usage) {
|
|
897
|
+
const usageMeta = this.mapUsageToMetadata(result.usage);
|
|
898
|
+
const content = {
|
|
899
|
+
speaker: 'ai',
|
|
900
|
+
blocks,
|
|
901
|
+
...(usageMeta
|
|
902
|
+
? {
|
|
903
|
+
metadata: {
|
|
904
|
+
usage: usageMeta,
|
|
905
|
+
},
|
|
906
|
+
}
|
|
907
|
+
: {}),
|
|
908
|
+
};
|
|
909
|
+
yield content;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Models listing – uses HTTP GET /models via fetch instead of the OpenAI SDK.
|
|
915
|
+
* Falls back to a small static list if the request fails.
|
|
916
|
+
*/
|
|
917
|
+
async getModels() {
|
|
918
|
+
const logger = this.getLogger();
|
|
919
|
+
try {
|
|
920
|
+
const authToken = await this.getAuthToken();
|
|
921
|
+
const baseURL = this.getBaseURL() ?? 'https://api.openai.com/v1';
|
|
922
|
+
const url = baseURL.endsWith('/') || baseURL.endsWith('\\')
|
|
923
|
+
? `${baseURL}models`
|
|
924
|
+
: `${baseURL}/models`;
|
|
925
|
+
const headers = {
|
|
926
|
+
...(this.getCustomHeaders() ?? {}),
|
|
927
|
+
};
|
|
928
|
+
if (authToken) {
|
|
929
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
930
|
+
}
|
|
931
|
+
const res = await fetch(url, {
|
|
932
|
+
headers,
|
|
933
|
+
});
|
|
934
|
+
if (!res.ok) {
|
|
935
|
+
throw new Error(`HTTP ${res.status}`);
|
|
936
|
+
}
|
|
937
|
+
const data = (await res.json());
|
|
938
|
+
const models = [];
|
|
939
|
+
for (const model of data.data ?? []) {
|
|
940
|
+
// Filter out non-chat models (embeddings, audio, image, etc.)
|
|
941
|
+
if (!/embedding|whisper|audio|tts|image|vision|dall[- ]?e|moderation/i.test(model.id)) {
|
|
942
|
+
const contextWindow = model.context_window ??
|
|
943
|
+
model.contextWindow;
|
|
944
|
+
models.push({
|
|
945
|
+
id: model.id,
|
|
946
|
+
name: model.name ?? model.id,
|
|
947
|
+
provider: this.name,
|
|
948
|
+
supportedToolFormats: ['openai'],
|
|
949
|
+
...(typeof contextWindow === 'number'
|
|
950
|
+
? { contextWindow }
|
|
951
|
+
: undefined),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const sortedModels = models.length > 0
|
|
956
|
+
? models.sort((a, b) => a.name.localeCompare(b.name))
|
|
957
|
+
: this.getFallbackModels();
|
|
958
|
+
return sortedModels;
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
logger.debug(() => `Error fetching models from OpenAI via Vercel provider: ${error}`);
|
|
962
|
+
return this.getFallbackModels();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
getFallbackModels() {
|
|
966
|
+
const providerName = this.name;
|
|
967
|
+
const models = [
|
|
968
|
+
{
|
|
969
|
+
id: 'gpt-3.5-turbo',
|
|
970
|
+
name: 'GPT-3.5 Turbo',
|
|
971
|
+
provider: providerName,
|
|
972
|
+
supportedToolFormats: ['openai'],
|
|
973
|
+
contextWindow: 16385,
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
id: 'gpt-4',
|
|
977
|
+
name: 'GPT-4',
|
|
978
|
+
provider: providerName,
|
|
979
|
+
supportedToolFormats: ['openai'],
|
|
980
|
+
contextWindow: 8192,
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
id: 'gpt-4-turbo',
|
|
984
|
+
name: 'GPT-4 Turbo',
|
|
985
|
+
provider: providerName,
|
|
986
|
+
supportedToolFormats: ['openai'],
|
|
987
|
+
contextWindow: 128000,
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
id: 'gpt-4o',
|
|
991
|
+
name: 'GPT-4o',
|
|
992
|
+
provider: providerName,
|
|
993
|
+
supportedToolFormats: ['openai'],
|
|
994
|
+
contextWindow: 128000,
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
id: 'gpt-4o-mini',
|
|
998
|
+
name: 'GPT-4o Mini',
|
|
999
|
+
provider: providerName,
|
|
1000
|
+
supportedToolFormats: ['openai'],
|
|
1001
|
+
contextWindow: 128000,
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
id: 'o1-mini',
|
|
1005
|
+
name: 'o1-mini',
|
|
1006
|
+
provider: providerName,
|
|
1007
|
+
supportedToolFormats: ['openai'],
|
|
1008
|
+
contextWindow: 128000,
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
id: 'o1-preview',
|
|
1012
|
+
name: 'o1-preview',
|
|
1013
|
+
provider: providerName,
|
|
1014
|
+
supportedToolFormats: ['openai'],
|
|
1015
|
+
contextWindow: 128000,
|
|
1016
|
+
},
|
|
1017
|
+
];
|
|
1018
|
+
return models.sort((a, b) => a.name.localeCompare(b.name));
|
|
1019
|
+
}
|
|
1020
|
+
getDefaultModel() {
|
|
1021
|
+
const baseURL = this.getBaseURL();
|
|
1022
|
+
if (baseURL &&
|
|
1023
|
+
(baseURL.includes('qwen') || baseURL.includes('dashscope'))) {
|
|
1024
|
+
return process.env.LLXPRT_DEFAULT_MODEL || 'qwen3-coder-plus';
|
|
1025
|
+
}
|
|
1026
|
+
return process.env.LLXPRT_DEFAULT_MODEL || 'gpt-4o';
|
|
1027
|
+
}
|
|
1028
|
+
getCurrentModel() {
|
|
1029
|
+
return this.getModel();
|
|
1030
|
+
}
|
|
1031
|
+
// No client caching for AI SDK provider – kept as no-op for compatibility.
|
|
1032
|
+
clearClientCache(runtimeKey) {
|
|
1033
|
+
void runtimeKey;
|
|
1034
|
+
}
|
|
1035
|
+
clearState() {
|
|
1036
|
+
this.clearClientCache();
|
|
1037
|
+
this.clearAuthCache();
|
|
1038
|
+
}
|
|
1039
|
+
getServerTools() {
|
|
1040
|
+
return [];
|
|
1041
|
+
}
|
|
1042
|
+
async invokeServerTool(toolName, _params, _config, _signal) {
|
|
1043
|
+
throw new Error(`Server tool '${toolName}' not supported by OpenAIVercelProvider`);
|
|
1044
|
+
}
|
|
1045
|
+
getToolFormat() {
|
|
1046
|
+
const format = this.detectToolFormat();
|
|
1047
|
+
const logger = new DebugLogger('llxprt:provider:openaivercel');
|
|
1048
|
+
logger.debug(() => `getToolFormat() called, returning: ${format}`, {
|
|
1049
|
+
provider: this.name,
|
|
1050
|
+
model: this.getModel(),
|
|
1051
|
+
format,
|
|
1052
|
+
});
|
|
1053
|
+
return format;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Detects the tool call format based on the model being used.
|
|
1057
|
+
* Mirrors OpenAIProvider behavior so existing ToolFormatter logic works.
|
|
1058
|
+
*/
|
|
1059
|
+
detectToolFormat() {
|
|
1060
|
+
const modelName = this.getModel() || this.getDefaultModel();
|
|
1061
|
+
const logger = new DebugLogger('llxprt:provider:openaivercel');
|
|
1062
|
+
// Check for Kimi K2 models (requires special ID format: functions.{name}:{index})
|
|
1063
|
+
if (isKimiModel(modelName)) {
|
|
1064
|
+
logger.debug(() => `Auto-detected 'kimi' format for K2 model: ${modelName}`);
|
|
1065
|
+
return 'kimi';
|
|
1066
|
+
}
|
|
1067
|
+
const lowerModelName = modelName.toLowerCase();
|
|
1068
|
+
if (lowerModelName.includes('glm-4')) {
|
|
1069
|
+
logger.debug(() => `Auto-detected 'qwen' format for GLM-4.x model: ${modelName}`);
|
|
1070
|
+
return 'qwen';
|
|
1071
|
+
}
|
|
1072
|
+
if (lowerModelName.includes('qwen')) {
|
|
1073
|
+
logger.debug(() => `Auto-detected 'qwen' format for Qwen model: ${modelName}`);
|
|
1074
|
+
return 'qwen';
|
|
1075
|
+
}
|
|
1076
|
+
logger.debug(() => `Using default 'openai' format for model: ${modelName}`);
|
|
1077
|
+
return 'openai';
|
|
1078
|
+
}
|
|
1079
|
+
parseToolResponse(response) {
|
|
1080
|
+
return response;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Disallow memoization of model params to preserve stateless behavior.
|
|
1084
|
+
*/
|
|
1085
|
+
setModelParams(_params) {
|
|
1086
|
+
throw new Error('ProviderCacheError("Attempted to memoize model parameters for openaivercel")');
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Gets model parameters from SettingsService per call (stateless).
|
|
1090
|
+
* Mirrors OpenAIProvider.getModelParams for compatibility.
|
|
1091
|
+
*/
|
|
1092
|
+
getModelParams() {
|
|
1093
|
+
try {
|
|
1094
|
+
const settingsService = this.resolveSettingsService();
|
|
1095
|
+
const providerSettings = settingsService.getProviderSettings(this.name);
|
|
1096
|
+
const reservedKeys = new Set([
|
|
1097
|
+
'enabled',
|
|
1098
|
+
'apiKey',
|
|
1099
|
+
'api-key',
|
|
1100
|
+
'apiKeyfile',
|
|
1101
|
+
'api-keyfile',
|
|
1102
|
+
'baseUrl',
|
|
1103
|
+
'base-url',
|
|
1104
|
+
'model',
|
|
1105
|
+
'toolFormat',
|
|
1106
|
+
'tool-format',
|
|
1107
|
+
'toolFormatOverride',
|
|
1108
|
+
'tool-format-override',
|
|
1109
|
+
'defaultModel',
|
|
1110
|
+
]);
|
|
1111
|
+
const params = {};
|
|
1112
|
+
if (providerSettings) {
|
|
1113
|
+
for (const [key, value] of Object.entries(providerSettings)) {
|
|
1114
|
+
if (reservedKeys.has(key) || value === undefined || value === null) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
params[key] = value;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return Object.keys(params).length > 0 ? params : undefined;
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
this.getLogger().debug(() => `Failed to get OpenAIVercel provider settings from SettingsService: ${error}`);
|
|
1124
|
+
return undefined;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Determines whether a response should be retried based on error codes.
|
|
1129
|
+
*
|
|
1130
|
+
* This is retained for compatibility with existing retryWithBackoff
|
|
1131
|
+
* callers, even though AI SDK's generateText/streamText have their
|
|
1132
|
+
* own built-in retry logic.
|
|
1133
|
+
*/
|
|
1134
|
+
shouldRetryResponse(error) {
|
|
1135
|
+
const logger = new DebugLogger('llxprt:provider:openaivercel');
|
|
1136
|
+
// Don't retry if it's a "successful" 200 error wrapper
|
|
1137
|
+
if (error &&
|
|
1138
|
+
typeof error === 'object' &&
|
|
1139
|
+
'status' in error &&
|
|
1140
|
+
error.status === 200) {
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
let status;
|
|
1144
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
1145
|
+
status = error.status;
|
|
1146
|
+
}
|
|
1147
|
+
if (!status && error && typeof error === 'object' && 'response' in error) {
|
|
1148
|
+
const response = error.response;
|
|
1149
|
+
if (response && typeof response === 'object' && 'status' in response) {
|
|
1150
|
+
status = response.status;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (!status && error instanceof Error) {
|
|
1154
|
+
if (error.message.includes('429')) {
|
|
1155
|
+
status = 429;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
logger.debug(() => `shouldRetryResponse checking error:`, {
|
|
1159
|
+
hasError: !!error,
|
|
1160
|
+
errorType: error && typeof error === 'object'
|
|
1161
|
+
? error.constructor?.name
|
|
1162
|
+
: undefined,
|
|
1163
|
+
status,
|
|
1164
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
1165
|
+
errorKeys: error && typeof error === 'object' ? Object.keys(error) : [],
|
|
1166
|
+
errorData: error && typeof error === 'object' && 'error' in error
|
|
1167
|
+
? error.error
|
|
1168
|
+
: undefined,
|
|
1169
|
+
});
|
|
1170
|
+
const shouldRetry = Boolean(status === 429 || status === 503 || status === 504);
|
|
1171
|
+
if (shouldRetry) {
|
|
1172
|
+
logger.debug(() => `Will retry request due to status ${status}`);
|
|
1173
|
+
}
|
|
1174
|
+
return shouldRetry;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
//# sourceMappingURL=OpenAIVercelProvider.js.map
|