@veltra/ai 2.2.2 → 2.2.3
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.
|
@@ -19,7 +19,8 @@ export type OpenAITransport = ChatTransport & {
|
|
|
19
19
|
};
|
|
20
20
|
/**
|
|
21
21
|
* 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
|
|
22
|
-
* 按 request.model 路由到对应 Provider 的 endpoint
|
|
22
|
+
* 按 request.model 路由到对应 Provider 的 endpoint;
|
|
23
|
+
* 请求体构造、fetch、SSE 解析与错误处理复用 `@veltra/utils` 通用层。
|
|
23
24
|
*/
|
|
24
25
|
export declare function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport;
|
|
25
26
|
//#endregion
|
|
@@ -1,171 +1,5 @@
|
|
|
1
|
+
import { buildOpenAIBody, requestOpenAIStream } from "@veltra/utils";
|
|
1
2
|
//#region src/chat/transports/openai.ts
|
|
2
|
-
function toOpenAIMessages(messages, systemPrompt) {
|
|
3
|
-
const result = [];
|
|
4
|
-
if (systemPrompt) result.push({
|
|
5
|
-
role: "system",
|
|
6
|
-
content: systemPrompt
|
|
7
|
-
});
|
|
8
|
-
for (const msg of messages) if (msg.role === "user") {
|
|
9
|
-
if (msg.attachments?.length) {
|
|
10
|
-
const parts = [];
|
|
11
|
-
if (msg.content) parts.push({
|
|
12
|
-
type: "text",
|
|
13
|
-
text: msg.content
|
|
14
|
-
});
|
|
15
|
-
for (const att of msg.attachments) parts.push({
|
|
16
|
-
type: "image_url",
|
|
17
|
-
image_url: { url: att.dataUrl }
|
|
18
|
-
});
|
|
19
|
-
result.push({
|
|
20
|
-
role: "user",
|
|
21
|
-
content: parts
|
|
22
|
-
});
|
|
23
|
-
} else result.push({
|
|
24
|
-
role: "user",
|
|
25
|
-
content: msg.content
|
|
26
|
-
});
|
|
27
|
-
} else if (msg.role === "assistant") {
|
|
28
|
-
const item = {
|
|
29
|
-
role: "assistant",
|
|
30
|
-
content: msg.content || ""
|
|
31
|
-
};
|
|
32
|
-
if (msg.toolCalls?.length) item.tool_calls = msg.toolCalls.map((call) => ({
|
|
33
|
-
id: call.id,
|
|
34
|
-
type: "function",
|
|
35
|
-
function: {
|
|
36
|
-
name: call.name,
|
|
37
|
-
arguments: call.arguments
|
|
38
|
-
}
|
|
39
|
-
}));
|
|
40
|
-
result.push(item);
|
|
41
|
-
} else if (msg.role === "tool") result.push({
|
|
42
|
-
role: "tool",
|
|
43
|
-
tool_call_id: msg.toolCallId,
|
|
44
|
-
content: msg.content
|
|
45
|
-
});
|
|
46
|
-
return result;
|
|
47
|
-
}
|
|
48
|
-
function toOpenAITools(tools) {
|
|
49
|
-
return tools.map((tool) => ({
|
|
50
|
-
type: "function",
|
|
51
|
-
function: {
|
|
52
|
-
name: tool.name,
|
|
53
|
-
description: tool.description,
|
|
54
|
-
parameters: tool.parameters
|
|
55
|
-
}
|
|
56
|
-
}));
|
|
57
|
-
}
|
|
58
|
-
function isRecord(value) {
|
|
59
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
-
}
|
|
61
|
-
/** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
|
|
62
|
-
function readCount(value) {
|
|
63
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
|
|
64
|
-
return Math.floor(value);
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* 从 OpenAI 兼容 usage 对象解析。
|
|
68
|
-
* 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
|
|
69
|
-
*/
|
|
70
|
-
function parseOpenAIUsage(raw) {
|
|
71
|
-
if (!isRecord(raw)) return null;
|
|
72
|
-
const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens);
|
|
73
|
-
const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens);
|
|
74
|
-
if (promptTokens == null && completionTokens == null) return null;
|
|
75
|
-
const prompt = promptTokens ?? 0;
|
|
76
|
-
const completion = completionTokens ?? 0;
|
|
77
|
-
const usage = {
|
|
78
|
-
promptTokens: prompt,
|
|
79
|
-
completionTokens: completion,
|
|
80
|
-
totalTokens: readCount(raw.total_tokens) ?? prompt + completion
|
|
81
|
-
};
|
|
82
|
-
const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : void 0;
|
|
83
|
-
const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : void 0;
|
|
84
|
-
const cacheHitTokens = readCount(details?.cached_tokens) ?? readCount(inputDetails?.cached_tokens) ?? readCount(raw.prompt_cache_hit_tokens) ?? readCount(raw.cache_read_input_tokens) ?? readCount(raw.cached_tokens);
|
|
85
|
-
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens;
|
|
86
|
-
const cacheMissTokens = readCount(raw.prompt_cache_miss_tokens) ?? (cacheHitTokens != null && promptTokens != null ? Math.max(0, promptTokens - cacheHitTokens) : void 0);
|
|
87
|
-
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens;
|
|
88
|
-
const cacheCreationTokens = readCount(raw.cache_creation_input_tokens);
|
|
89
|
-
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens;
|
|
90
|
-
return usage;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* 逐行解析 SSE 流。
|
|
94
|
-
* 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
|
|
95
|
-
*/
|
|
96
|
-
async function parseSSE(body, signal, handlers) {
|
|
97
|
-
const reader = body.getReader();
|
|
98
|
-
const decoder = new TextDecoder();
|
|
99
|
-
let buffer = "";
|
|
100
|
-
const accumulated = /* @__PURE__ */ new Map();
|
|
101
|
-
let lastIndex = -1;
|
|
102
|
-
const flushToolCalls = () => {
|
|
103
|
-
if (!handlers.onToolCall) return;
|
|
104
|
-
for (const call of accumulated.values()) if (call.id && call.name) handlers.onToolCall({
|
|
105
|
-
id: call.id,
|
|
106
|
-
name: call.name,
|
|
107
|
-
arguments: call.arguments
|
|
108
|
-
});
|
|
109
|
-
accumulated.clear();
|
|
110
|
-
};
|
|
111
|
-
const handleData = (data) => {
|
|
112
|
-
if (data === "[DONE]") return;
|
|
113
|
-
let chunk;
|
|
114
|
-
try {
|
|
115
|
-
chunk = JSON.parse(data);
|
|
116
|
-
} catch {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (!isRecord(chunk)) return;
|
|
120
|
-
const usage = parseOpenAIUsage(chunk.usage);
|
|
121
|
-
if (usage) handlers.onUsage?.(usage);
|
|
122
|
-
const choices = chunk.choices;
|
|
123
|
-
const first = Array.isArray(choices) ? choices[0] : void 0;
|
|
124
|
-
const delta = isRecord(first) ? first.delta : void 0;
|
|
125
|
-
if (!isRecord(delta)) return;
|
|
126
|
-
if (typeof delta.content === "string" && delta.content) handlers.onTextDelta(delta.content);
|
|
127
|
-
const reasoning = delta.reasoning_content ?? delta.reasoning;
|
|
128
|
-
if (typeof reasoning === "string" && reasoning) handlers.onReasoningDelta?.(reasoning);
|
|
129
|
-
if (Array.isArray(delta.tool_calls)) for (const tc of delta.tool_calls) {
|
|
130
|
-
if (!isRecord(tc)) continue;
|
|
131
|
-
const index = typeof tc.index === "number" ? tc.index : 0;
|
|
132
|
-
if (index > lastIndex && accumulated.size > 0) flushToolCalls();
|
|
133
|
-
lastIndex = Math.max(lastIndex, index);
|
|
134
|
-
let call = accumulated.get(index);
|
|
135
|
-
if (!call) {
|
|
136
|
-
call = {
|
|
137
|
-
id: "",
|
|
138
|
-
name: "",
|
|
139
|
-
arguments: ""
|
|
140
|
-
};
|
|
141
|
-
accumulated.set(index, call);
|
|
142
|
-
}
|
|
143
|
-
if (typeof tc.id === "string") call.id = tc.id;
|
|
144
|
-
const fn = isRecord(tc.function) ? tc.function : void 0;
|
|
145
|
-
if (typeof fn?.name === "string") call.name += fn.name;
|
|
146
|
-
if (typeof fn?.arguments === "string") call.arguments += fn.arguments;
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
try {
|
|
150
|
-
const pump = async () => {
|
|
151
|
-
const { done, value } = await reader.read();
|
|
152
|
-
if (done || signal.aborted) return;
|
|
153
|
-
buffer += decoder.decode(value, { stream: true });
|
|
154
|
-
let newlineIndex;
|
|
155
|
-
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
|
|
156
|
-
const line = buffer.slice(0, newlineIndex).trim();
|
|
157
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
158
|
-
if (line.startsWith("data:")) handleData(line.slice(5).trim());
|
|
159
|
-
}
|
|
160
|
-
return pump();
|
|
161
|
-
};
|
|
162
|
-
await pump();
|
|
163
|
-
if (buffer.trim().startsWith("data:")) handleData(buffer.trim().slice(5).trim());
|
|
164
|
-
} finally {
|
|
165
|
-
reader.releaseLock();
|
|
166
|
-
}
|
|
167
|
-
flushToolCalls();
|
|
168
|
-
}
|
|
169
3
|
/** 校验 providers 并展开扁平模型列表 */
|
|
170
4
|
function flattenModels(providers) {
|
|
171
5
|
if (!providers.length) throw new Error("[createOpenAITransport] providers 不能为空");
|
|
@@ -188,13 +22,10 @@ function flattenModels(providers) {
|
|
|
188
22
|
byModelId
|
|
189
23
|
};
|
|
190
24
|
}
|
|
191
|
-
/** 缺省:写入 OpenAI 兼容的 reasoning_effort */
|
|
192
|
-
function defaultApplyReasoning(level, body) {
|
|
193
|
-
body.reasoning_effort = level;
|
|
194
|
-
}
|
|
195
25
|
/**
|
|
196
26
|
* 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
|
|
197
|
-
* 按 request.model 路由到对应 Provider 的 endpoint
|
|
27
|
+
* 按 request.model 路由到对应 Provider 的 endpoint;
|
|
28
|
+
* 请求体构造、fetch、SSE 解析与错误处理复用 `@veltra/utils` 通用层。
|
|
198
29
|
*/
|
|
199
30
|
function createOpenAITransport(options) {
|
|
200
31
|
const { providers, headers: globalHeaders, body: globalBody } = options;
|
|
@@ -208,50 +39,26 @@ function createOpenAITransport(options) {
|
|
|
208
39
|
handlers.onError?.(/* @__PURE__ */ new Error(`未找到模型 "${modelId}" 对应的 Provider`));
|
|
209
40
|
return;
|
|
210
41
|
}
|
|
211
|
-
const
|
|
212
|
-
const requestBody = {
|
|
213
|
-
...globalBody,
|
|
42
|
+
const requestBody = buildOpenAIBody({
|
|
214
43
|
model: modelId,
|
|
44
|
+
messages,
|
|
45
|
+
systemPrompt,
|
|
46
|
+
tools,
|
|
215
47
|
stream: true,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
48
|
+
reasoningLevel,
|
|
49
|
+
applyReasoning: provider.applyReasoning,
|
|
50
|
+
extraBody: globalBody
|
|
51
|
+
});
|
|
52
|
+
await requestOpenAIStream({
|
|
53
|
+
url: provider.endpoint,
|
|
54
|
+
apiKey: provider.apiKey,
|
|
55
|
+
headers: {
|
|
56
|
+
...globalHeaders,
|
|
57
|
+
...provider.headers
|
|
219
58
|
},
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
};
|
|
223
|
-
if (reasoningLevel) (provider.applyReasoning ?? defaultApplyReasoning)(reasoningLevel, requestBody);
|
|
224
|
-
let response;
|
|
225
|
-
try {
|
|
226
|
-
response = await fetch(provider.endpoint, {
|
|
227
|
-
method: "POST",
|
|
228
|
-
signal,
|
|
229
|
-
headers: {
|
|
230
|
-
"Content-Type": "application/json",
|
|
231
|
-
...provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {},
|
|
232
|
-
...globalHeaders,
|
|
233
|
-
...provider.headers
|
|
234
|
-
},
|
|
235
|
-
body: JSON.stringify(requestBody)
|
|
236
|
-
});
|
|
237
|
-
} catch (error) {
|
|
238
|
-
if (!signal.aborted) handlers.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
if (!response.ok) {
|
|
242
|
-
const text = await response.text().catch(() => "");
|
|
243
|
-
handlers.onError?.(/* @__PURE__ */ new Error(`请求失败(${response.status}):${text || response.statusText}`));
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (!response.body) {
|
|
247
|
-
handlers.onError?.(/* @__PURE__ */ new Error("响应不包含可读取的流"));
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
await parseSSE(response.body, signal, handlers);
|
|
252
|
-
} catch (error) {
|
|
253
|
-
if (!signal.aborted) handlers.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
254
|
-
}
|
|
59
|
+
body: requestBody,
|
|
60
|
+
signal
|
|
61
|
+
}, handlers);
|
|
255
62
|
};
|
|
256
63
|
return Object.assign(send, {
|
|
257
64
|
models,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"openai.js","names":[],"sources":["../../../src/chat/transports/openai.ts"],"sourcesContent":["import type { ChatModelOption, ChatProvider } from '../../providers'\nimport type {\n ChatMessage,\n ChatTokenUsage,\n ChatTool,\n ChatTransport,\n ChatTransportHandlers,\n ChatTransportRequest\n} from '../types'\n\n/** createOpenAITransport 的配置项(多 Provider) */\nexport interface OpenAITransportOptions {\n /** 至少一个 Provider;模型 id 须跨 Provider 全局唯一 */\n providers: ChatProvider[]\n /** 全局额外请求头(与各 Provider headers 合并,Provider 优先) */\n headers?: Record<string, string>\n /** 全局额外请求体字段,如 temperature、top_p */\n body?: Record<string, unknown>\n}\n\n/** 带扁平模型列表元数据的 OpenAI 兼容 transport */\nexport type OpenAITransport = ChatTransport & {\n /** 供 UI 使用的扁平模型列表 */\n readonly models: ChatModelOption[]\n /** 默认模型 id(首个 Provider 的首个模型) */\n readonly defaultModel: string\n}\n\ninterface OpenAIMessage {\n role: string\n content?: unknown\n tool_calls?: unknown[]\n tool_call_id?: string\n}\n\nfunction toOpenAIMessages(messages: ChatMessage[], systemPrompt?: string): OpenAIMessage[] {\n const result: OpenAIMessage[] = []\n\n if (systemPrompt) {\n result.push({ role: 'system', content: systemPrompt })\n }\n\n for (const msg of messages) {\n if (msg.role === 'user') {\n if (msg.attachments?.length) {\n const parts: unknown[] = []\n if (msg.content) parts.push({ type: 'text', text: msg.content })\n for (const att of msg.attachments) {\n parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })\n }\n result.push({ role: 'user', content: parts })\n } else {\n result.push({ role: 'user', content: msg.content })\n }\n } else if (msg.role === 'assistant') {\n const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }\n if (msg.toolCalls?.length) {\n item.tool_calls = msg.toolCalls.map((call) => ({\n id: call.id,\n type: 'function',\n function: { name: call.name, arguments: call.arguments }\n }))\n }\n result.push(item)\n } else if (msg.role === 'tool') {\n result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })\n }\n }\n\n return result\n}\n\nfunction toOpenAITools(tools: ChatTool[]): unknown[] {\n return tools.map((tool) => ({\n type: 'function',\n function: { name: tool.name, description: tool.description, parameters: tool.parameters }\n }))\n}\n\ninterface AccumulatedToolCall {\n id: string\n name: string\n arguments: string\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */\nfunction readCount(value: unknown): number | undefined {\n if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined\n return Math.floor(value)\n}\n\n/**\n * 从 OpenAI 兼容 usage 对象解析。\n * 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。\n */\nfunction parseOpenAIUsage(raw: unknown): ChatTokenUsage | null {\n if (!isRecord(raw)) return null\n\n const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)\n const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)\n if (promptTokens == null && completionTokens == null) return null\n\n const prompt = promptTokens ?? 0\n const completion = completionTokens ?? 0\n const usage: ChatTokenUsage = {\n promptTokens: prompt,\n completionTokens: completion,\n totalTokens: readCount(raw.total_tokens) ?? prompt + completion\n }\n\n const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined\n const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined\n const cacheHitTokens =\n readCount(details?.cached_tokens) ??\n readCount(inputDetails?.cached_tokens) ??\n readCount(raw.prompt_cache_hit_tokens) ??\n readCount(raw.cache_read_input_tokens) ??\n readCount(raw.cached_tokens)\n if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens\n\n const cacheMissTokens =\n readCount(raw.prompt_cache_miss_tokens) ??\n (cacheHitTokens != null && promptTokens != null\n ? Math.max(0, promptTokens - cacheHitTokens)\n : undefined)\n if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens\n\n const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)\n if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens\n\n return usage\n}\n\n/**\n * 逐行解析 SSE 流。\n * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。\n */\nasync function parseSSE(\n body: ReadableStream<Uint8Array>,\n signal: AbortSignal,\n handlers: ChatTransportHandlers\n): Promise<void> {\n const reader = body.getReader()\n const decoder = new TextDecoder()\n let buffer = ''\n\n // 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段\n const accumulated = new Map<number, AccumulatedToolCall>()\n let lastIndex = -1\n\n const flushToolCalls = () => {\n if (!handlers.onToolCall) return\n for (const call of accumulated.values()) {\n if (call.id && call.name) {\n handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })\n }\n }\n accumulated.clear()\n }\n\n const handleData = (data: string) => {\n if (data === '[DONE]') return\n\n let chunk: unknown\n try {\n chunk = JSON.parse(data)\n } catch {\n return\n }\n if (!isRecord(chunk)) return\n\n const usage = parseOpenAIUsage(chunk.usage)\n if (usage) handlers.onUsage?.(usage)\n\n const choices = chunk.choices\n const first = Array.isArray(choices) ? choices[0] : undefined\n const delta = isRecord(first) ? first.delta : undefined\n if (!isRecord(delta)) return\n\n if (typeof delta.content === 'string' && delta.content) {\n handlers.onTextDelta(delta.content)\n }\n\n // 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)\n const reasoning = delta.reasoning_content ?? delta.reasoning\n if (typeof reasoning === 'string' && reasoning) {\n handlers.onReasoningDelta?.(reasoning)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n for (const tc of delta.tool_calls) {\n if (!isRecord(tc)) continue\n const index = typeof tc.index === 'number' ? tc.index : 0\n // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去\n if (index > lastIndex && accumulated.size > 0) flushToolCalls()\n lastIndex = Math.max(lastIndex, index)\n\n let call = accumulated.get(index)\n if (!call) {\n call = { id: '', name: '', arguments: '' }\n accumulated.set(index, call)\n }\n if (typeof tc.id === 'string') call.id = tc.id\n const fn = isRecord(tc.function) ? tc.function : undefined\n if (typeof fn?.name === 'string') call.name += fn.name\n if (typeof fn?.arguments === 'string') call.arguments += fn.arguments\n }\n }\n }\n\n try {\n // 递归泵取代替 while 循环,避免 await-in-loop\n const pump = async (): Promise<void> => {\n const { done, value } = await reader.read()\n if (done || signal.aborted) return\n\n buffer += decoder.decode(value, { stream: true })\n\n let newlineIndex: number\n while ((newlineIndex = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newlineIndex).trim()\n buffer = buffer.slice(newlineIndex + 1)\n if (line.startsWith('data:')) {\n handleData(line.slice(5).trim())\n }\n }\n\n return pump()\n }\n\n await pump()\n\n if (buffer.trim().startsWith('data:')) {\n handleData(buffer.trim().slice(5).trim())\n }\n } finally {\n reader.releaseLock()\n }\n\n flushToolCalls()\n}\n\n/** 校验 providers 并展开扁平模型列表 */\nfunction flattenModels(providers: ChatProvider[]): {\n models: ChatModelOption[]\n byModelId: Map<string, ChatProvider>\n} {\n if (!providers.length) {\n throw new Error('[createOpenAITransport] providers 不能为空')\n }\n\n const models: ChatModelOption[] = []\n const byModelId = new Map<string, ChatProvider>()\n\n for (const provider of providers) {\n if (!provider.models?.length) {\n throw new Error(`[createOpenAITransport] Provider \"${provider.id}\" 未配置 models`)\n }\n for (const model of provider.models) {\n if (byModelId.has(model.id)) {\n throw new Error(\n `[createOpenAITransport] 模型 id \"${model.id}\" 重复(跨 Provider 须全局唯一)`\n )\n }\n byModelId.set(model.id, provider)\n models.push({ ...model, providerId: provider.id, providerLabel: provider.label })\n }\n }\n\n return { models, byModelId }\n}\n\n/** 缺省:写入 OpenAI 兼容的 reasoning_effort */\nfunction defaultApplyReasoning(level: string, body: Record<string, unknown>) {\n body.reasoning_effort = level\n}\n\n/**\n * 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。\n * 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。\n */\nexport function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {\n const { providers, headers: globalHeaders, body: globalBody } = options\n const { models, byModelId } = flattenModels(providers)\n const defaultModel = models[0]!.id\n\n const send: ChatTransport = async (\n request: ChatTransportRequest,\n handlers: ChatTransportHandlers\n ) => {\n const { messages, systemPrompt, tools, signal, reasoningLevel } = request\n const modelId = request.model ?? defaultModel\n const provider = byModelId.get(modelId)\n\n if (!provider) {\n handlers.onError?.(new Error(`未找到模型 \"${modelId}\" 对应的 Provider`))\n return\n }\n\n const extraStreamOptions = isRecord(globalBody?.stream_options)\n ? globalBody.stream_options\n : undefined\n const requestBody: Record<string, unknown> = {\n ...globalBody,\n model: modelId,\n stream: true,\n // 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回\n stream_options: { ...extraStreamOptions, include_usage: true },\n messages: toOpenAIMessages(messages, systemPrompt),\n ...(tools?.length ? { tools: toOpenAITools(tools) } : {})\n }\n\n if (reasoningLevel) {\n const apply = provider.applyReasoning ?? defaultApplyReasoning\n apply(reasoningLevel, requestBody)\n }\n\n let response: Response\n try {\n response = await fetch(provider.endpoint, {\n method: 'POST',\n signal,\n headers: {\n 'Content-Type': 'application/json',\n ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),\n ...globalHeaders,\n ...provider.headers\n },\n body: JSON.stringify(requestBody)\n })\n } catch (error) {\n if (!signal.aborted) {\n handlers.onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n return\n }\n\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n handlers.onError?.(\n new Error(`请求失败(${response.status}):${text || response.statusText}`)\n )\n return\n }\n\n if (!response.body) {\n handlers.onError?.(new Error('响应不包含可读取的流'))\n return\n }\n\n try {\n await parseSSE(response.body, signal, handlers)\n } catch (error) {\n if (!signal.aborted) {\n handlers.onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n }\n }\n\n return Object.assign(send, { models, defaultModel })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"openai.js","names":[],"sources":["../../../src/chat/transports/openai.ts"],"sourcesContent":["import { buildOpenAIBody, requestOpenAIStream } from '@veltra/utils'\n\nimport type { ChatModelOption, ChatProvider } from '../../providers'\nimport type { ChatTransport, ChatTransportHandlers, ChatTransportRequest } from '../types'\n\n/** createOpenAITransport 的配置项(多 Provider) */\nexport interface OpenAITransportOptions {\n /** 至少一个 Provider;模型 id 须跨 Provider 全局唯一 */\n providers: ChatProvider[]\n /** 全局额外请求头(与各 Provider headers 合并,Provider 优先) */\n headers?: Record<string, string>\n /** 全局额外请求体字段,如 temperature、top_p */\n body?: Record<string, unknown>\n}\n\n/** 带扁平模型列表元数据的 OpenAI 兼容 transport */\nexport type OpenAITransport = ChatTransport & {\n /** 供 UI 使用的扁平模型列表 */\n readonly models: ChatModelOption[]\n /** 默认模型 id(首个 Provider 的首个模型) */\n readonly defaultModel: string\n}\n\n/** 校验 providers 并展开扁平模型列表 */\nfunction flattenModels(providers: ChatProvider[]): {\n models: ChatModelOption[]\n byModelId: Map<string, ChatProvider>\n} {\n if (!providers.length) {\n throw new Error('[createOpenAITransport] providers 不能为空')\n }\n\n const models: ChatModelOption[] = []\n const byModelId = new Map<string, ChatProvider>()\n\n for (const provider of providers) {\n if (!provider.models?.length) {\n throw new Error(`[createOpenAITransport] Provider \"${provider.id}\" 未配置 models`)\n }\n for (const model of provider.models) {\n if (byModelId.has(model.id)) {\n throw new Error(\n `[createOpenAITransport] 模型 id \"${model.id}\" 重复(跨 Provider 须全局唯一)`\n )\n }\n byModelId.set(model.id, provider)\n models.push({ ...model, providerId: provider.id, providerLabel: provider.label })\n }\n }\n\n return { models, byModelId }\n}\n\n/**\n * 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。\n * 按 request.model 路由到对应 Provider 的 endpoint;\n * 请求体构造、fetch、SSE 解析与错误处理复用 `@veltra/utils` 通用层。\n */\nexport function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {\n const { providers, headers: globalHeaders, body: globalBody } = options\n const { models, byModelId } = flattenModels(providers)\n const defaultModel = models[0]!.id\n\n const send: ChatTransport = async (\n request: ChatTransportRequest,\n handlers: ChatTransportHandlers\n ) => {\n const { messages, systemPrompt, tools, signal, reasoningLevel } = request\n const modelId = request.model ?? defaultModel\n const provider = byModelId.get(modelId)\n\n if (!provider) {\n handlers.onError?.(new Error(`未找到模型 \"${modelId}\" 对应的 Provider`))\n return\n }\n\n const requestBody = buildOpenAIBody({\n model: modelId,\n messages,\n systemPrompt,\n tools,\n stream: true,\n reasoningLevel,\n applyReasoning: provider.applyReasoning,\n extraBody: globalBody\n })\n\n // fetch + SSE 解析 + 错误处理均由通用层完成;错误经 onError 报告,signal 中断静默\n await requestOpenAIStream(\n {\n url: provider.endpoint,\n apiKey: provider.apiKey,\n headers: { ...globalHeaders, ...provider.headers },\n body: requestBody,\n signal\n },\n handlers\n )\n }\n\n return Object.assign(send, { models, defaultModel })\n}\n"],"mappings":";;;AAwBA,SAAS,cAAc,WAGrB;CACA,IAAI,CAAC,UAAU,QACb,MAAM,IAAI,MAAM,wCAAwC;CAG1D,MAAM,SAA4B,CAAC;CACnC,MAAM,4BAAY,IAAI,IAA0B;CAEhD,KAAK,MAAM,YAAY,WAAW;EAChC,IAAI,CAAC,SAAS,QAAQ,QACpB,MAAM,IAAI,MAAM,qCAAqC,SAAS,GAAG,aAAa;EAEhF,KAAK,MAAM,SAAS,SAAS,QAAQ;GACnC,IAAI,UAAU,IAAI,MAAM,EAAE,GACxB,MAAM,IAAI,MACR,kCAAkC,MAAM,GAAG,uBAC7C;GAEF,UAAU,IAAI,MAAM,IAAI,QAAQ;GAChC,OAAO,KAAK;IAAE,GAAG;IAAO,YAAY,SAAS;IAAI,eAAe,SAAS;GAAM,CAAC;EAClF;CACF;CAEA,OAAO;EAAE;EAAQ;CAAU;AAC7B;;;;;;AAOA,SAAgB,sBAAsB,SAAkD;CACtF,MAAM,EAAE,WAAW,SAAS,eAAe,MAAM,eAAe;CAChE,MAAM,EAAE,QAAQ,cAAc,cAAc,SAAS;CACrD,MAAM,eAAe,OAAO,EAAE,CAAE;CAEhC,MAAM,OAAsB,OAC1B,SACA,aACG;EACH,MAAM,EAAE,UAAU,cAAc,OAAO,QAAQ,mBAAmB;EAClE,MAAM,UAAU,QAAQ,SAAS;EACjC,MAAM,WAAW,UAAU,IAAI,OAAO;EAEtC,IAAI,CAAC,UAAU;GACb,SAAS,0BAAU,IAAI,MAAM,UAAU,QAAQ,eAAe,CAAC;GAC/D;EACF;EAEA,MAAM,cAAc,gBAAgB;GAClC,OAAO;GACP;GACA;GACA;GACA,QAAQ;GACR;GACA,gBAAgB,SAAS;GACzB,WAAW;EACb,CAAC;EAGD,MAAM,oBACJ;GACE,KAAK,SAAS;GACd,QAAQ,SAAS;GACjB,SAAS;IAAE,GAAG;IAAe,GAAG,SAAS;GAAQ;GACjD,MAAM;GACN;EACF,GACA,QACF;CACF;CAEA,OAAO,OAAO,OAAO,MAAM;EAAE;EAAQ;CAAa,CAAC;AACrD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veltra/ai",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/cabinet-fe/ultra-ui/issues"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"markstream-vue": "^2.0.
|
|
39
|
+
"markstream-vue": "^2.0.17"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@veltra/compositions": "^1.7.14",
|
|
@@ -1,12 +1,7 @@
|
|
|
1
|
+
import { buildOpenAIBody, requestOpenAIStream } from '@veltra/utils'
|
|
2
|
+
|
|
1
3
|
import type { ChatModelOption, ChatProvider } from '../../providers'
|
|
2
|
-
import type {
|
|
3
|
-
ChatMessage,
|
|
4
|
-
ChatTokenUsage,
|
|
5
|
-
ChatTool,
|
|
6
|
-
ChatTransport,
|
|
7
|
-
ChatTransportHandlers,
|
|
8
|
-
ChatTransportRequest
|
|
9
|
-
} from '../types'
|
|
4
|
+
import type { ChatTransport, ChatTransportHandlers, ChatTransportRequest } from '../types'
|
|
10
5
|
|
|
11
6
|
/** createOpenAITransport 的配置项(多 Provider) */
|
|
12
7
|
export interface OpenAITransportOptions {
|
|
@@ -26,224 +21,6 @@ export type OpenAITransport = ChatTransport & {
|
|
|
26
21
|
readonly defaultModel: string
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
interface OpenAIMessage {
|
|
30
|
-
role: string
|
|
31
|
-
content?: unknown
|
|
32
|
-
tool_calls?: unknown[]
|
|
33
|
-
tool_call_id?: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function toOpenAIMessages(messages: ChatMessage[], systemPrompt?: string): OpenAIMessage[] {
|
|
37
|
-
const result: OpenAIMessage[] = []
|
|
38
|
-
|
|
39
|
-
if (systemPrompt) {
|
|
40
|
-
result.push({ role: 'system', content: systemPrompt })
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
for (const msg of messages) {
|
|
44
|
-
if (msg.role === 'user') {
|
|
45
|
-
if (msg.attachments?.length) {
|
|
46
|
-
const parts: unknown[] = []
|
|
47
|
-
if (msg.content) parts.push({ type: 'text', text: msg.content })
|
|
48
|
-
for (const att of msg.attachments) {
|
|
49
|
-
parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })
|
|
50
|
-
}
|
|
51
|
-
result.push({ role: 'user', content: parts })
|
|
52
|
-
} else {
|
|
53
|
-
result.push({ role: 'user', content: msg.content })
|
|
54
|
-
}
|
|
55
|
-
} else if (msg.role === 'assistant') {
|
|
56
|
-
const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }
|
|
57
|
-
if (msg.toolCalls?.length) {
|
|
58
|
-
item.tool_calls = msg.toolCalls.map((call) => ({
|
|
59
|
-
id: call.id,
|
|
60
|
-
type: 'function',
|
|
61
|
-
function: { name: call.name, arguments: call.arguments }
|
|
62
|
-
}))
|
|
63
|
-
}
|
|
64
|
-
result.push(item)
|
|
65
|
-
} else if (msg.role === 'tool') {
|
|
66
|
-
result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return result
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function toOpenAITools(tools: ChatTool[]): unknown[] {
|
|
74
|
-
return tools.map((tool) => ({
|
|
75
|
-
type: 'function',
|
|
76
|
-
function: { name: tool.name, description: tool.description, parameters: tool.parameters }
|
|
77
|
-
}))
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface AccumulatedToolCall {
|
|
81
|
-
id: string
|
|
82
|
-
name: string
|
|
83
|
-
arguments: string
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
|
|
91
|
-
function readCount(value: unknown): number | undefined {
|
|
92
|
-
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
|
|
93
|
-
return Math.floor(value)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 从 OpenAI 兼容 usage 对象解析。
|
|
98
|
-
* 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
|
|
99
|
-
*/
|
|
100
|
-
function parseOpenAIUsage(raw: unknown): ChatTokenUsage | null {
|
|
101
|
-
if (!isRecord(raw)) return null
|
|
102
|
-
|
|
103
|
-
const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
|
|
104
|
-
const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
|
|
105
|
-
if (promptTokens == null && completionTokens == null) return null
|
|
106
|
-
|
|
107
|
-
const prompt = promptTokens ?? 0
|
|
108
|
-
const completion = completionTokens ?? 0
|
|
109
|
-
const usage: ChatTokenUsage = {
|
|
110
|
-
promptTokens: prompt,
|
|
111
|
-
completionTokens: completion,
|
|
112
|
-
totalTokens: readCount(raw.total_tokens) ?? prompt + completion
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
|
|
116
|
-
const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
|
|
117
|
-
const cacheHitTokens =
|
|
118
|
-
readCount(details?.cached_tokens) ??
|
|
119
|
-
readCount(inputDetails?.cached_tokens) ??
|
|
120
|
-
readCount(raw.prompt_cache_hit_tokens) ??
|
|
121
|
-
readCount(raw.cache_read_input_tokens) ??
|
|
122
|
-
readCount(raw.cached_tokens)
|
|
123
|
-
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
124
|
-
|
|
125
|
-
const cacheMissTokens =
|
|
126
|
-
readCount(raw.prompt_cache_miss_tokens) ??
|
|
127
|
-
(cacheHitTokens != null && promptTokens != null
|
|
128
|
-
? Math.max(0, promptTokens - cacheHitTokens)
|
|
129
|
-
: undefined)
|
|
130
|
-
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
131
|
-
|
|
132
|
-
const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
|
|
133
|
-
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
134
|
-
|
|
135
|
-
return usage
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* 逐行解析 SSE 流。
|
|
140
|
-
* 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
|
|
141
|
-
*/
|
|
142
|
-
async function parseSSE(
|
|
143
|
-
body: ReadableStream<Uint8Array>,
|
|
144
|
-
signal: AbortSignal,
|
|
145
|
-
handlers: ChatTransportHandlers
|
|
146
|
-
): Promise<void> {
|
|
147
|
-
const reader = body.getReader()
|
|
148
|
-
const decoder = new TextDecoder()
|
|
149
|
-
let buffer = ''
|
|
150
|
-
|
|
151
|
-
// 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段
|
|
152
|
-
const accumulated = new Map<number, AccumulatedToolCall>()
|
|
153
|
-
let lastIndex = -1
|
|
154
|
-
|
|
155
|
-
const flushToolCalls = () => {
|
|
156
|
-
if (!handlers.onToolCall) return
|
|
157
|
-
for (const call of accumulated.values()) {
|
|
158
|
-
if (call.id && call.name) {
|
|
159
|
-
handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
accumulated.clear()
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const handleData = (data: string) => {
|
|
166
|
-
if (data === '[DONE]') return
|
|
167
|
-
|
|
168
|
-
let chunk: unknown
|
|
169
|
-
try {
|
|
170
|
-
chunk = JSON.parse(data)
|
|
171
|
-
} catch {
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
if (!isRecord(chunk)) return
|
|
175
|
-
|
|
176
|
-
const usage = parseOpenAIUsage(chunk.usage)
|
|
177
|
-
if (usage) handlers.onUsage?.(usage)
|
|
178
|
-
|
|
179
|
-
const choices = chunk.choices
|
|
180
|
-
const first = Array.isArray(choices) ? choices[0] : undefined
|
|
181
|
-
const delta = isRecord(first) ? first.delta : undefined
|
|
182
|
-
if (!isRecord(delta)) return
|
|
183
|
-
|
|
184
|
-
if (typeof delta.content === 'string' && delta.content) {
|
|
185
|
-
handlers.onTextDelta(delta.content)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)
|
|
189
|
-
const reasoning = delta.reasoning_content ?? delta.reasoning
|
|
190
|
-
if (typeof reasoning === 'string' && reasoning) {
|
|
191
|
-
handlers.onReasoningDelta?.(reasoning)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (Array.isArray(delta.tool_calls)) {
|
|
195
|
-
for (const tc of delta.tool_calls) {
|
|
196
|
-
if (!isRecord(tc)) continue
|
|
197
|
-
const index = typeof tc.index === 'number' ? tc.index : 0
|
|
198
|
-
// 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
|
|
199
|
-
if (index > lastIndex && accumulated.size > 0) flushToolCalls()
|
|
200
|
-
lastIndex = Math.max(lastIndex, index)
|
|
201
|
-
|
|
202
|
-
let call = accumulated.get(index)
|
|
203
|
-
if (!call) {
|
|
204
|
-
call = { id: '', name: '', arguments: '' }
|
|
205
|
-
accumulated.set(index, call)
|
|
206
|
-
}
|
|
207
|
-
if (typeof tc.id === 'string') call.id = tc.id
|
|
208
|
-
const fn = isRecord(tc.function) ? tc.function : undefined
|
|
209
|
-
if (typeof fn?.name === 'string') call.name += fn.name
|
|
210
|
-
if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
// 递归泵取代替 while 循环,避免 await-in-loop
|
|
217
|
-
const pump = async (): Promise<void> => {
|
|
218
|
-
const { done, value } = await reader.read()
|
|
219
|
-
if (done || signal.aborted) return
|
|
220
|
-
|
|
221
|
-
buffer += decoder.decode(value, { stream: true })
|
|
222
|
-
|
|
223
|
-
let newlineIndex: number
|
|
224
|
-
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
225
|
-
const line = buffer.slice(0, newlineIndex).trim()
|
|
226
|
-
buffer = buffer.slice(newlineIndex + 1)
|
|
227
|
-
if (line.startsWith('data:')) {
|
|
228
|
-
handleData(line.slice(5).trim())
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return pump()
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
await pump()
|
|
236
|
-
|
|
237
|
-
if (buffer.trim().startsWith('data:')) {
|
|
238
|
-
handleData(buffer.trim().slice(5).trim())
|
|
239
|
-
}
|
|
240
|
-
} finally {
|
|
241
|
-
reader.releaseLock()
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
flushToolCalls()
|
|
245
|
-
}
|
|
246
|
-
|
|
247
24
|
/** 校验 providers 并展开扁平模型列表 */
|
|
248
25
|
function flattenModels(providers: ChatProvider[]): {
|
|
249
26
|
models: ChatModelOption[]
|
|
@@ -274,14 +51,10 @@ function flattenModels(providers: ChatProvider[]): {
|
|
|
274
51
|
return { models, byModelId }
|
|
275
52
|
}
|
|
276
53
|
|
|
277
|
-
/** 缺省:写入 OpenAI 兼容的 reasoning_effort */
|
|
278
|
-
function defaultApplyReasoning(level: string, body: Record<string, unknown>) {
|
|
279
|
-
body.reasoning_effort = level
|
|
280
|
-
}
|
|
281
|
-
|
|
282
54
|
/**
|
|
283
55
|
* 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
|
|
284
|
-
* 按 request.model 路由到对应 Provider 的 endpoint
|
|
56
|
+
* 按 request.model 路由到对应 Provider 的 endpoint;
|
|
57
|
+
* 请求体构造、fetch、SSE 解析与错误处理复用 `@veltra/utils` 通用层。
|
|
285
58
|
*/
|
|
286
59
|
export function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {
|
|
287
60
|
const { providers, headers: globalHeaders, body: globalBody } = options
|
|
@@ -301,64 +74,28 @@ export function createOpenAITransport(options: OpenAITransportOptions): OpenAITr
|
|
|
301
74
|
return
|
|
302
75
|
}
|
|
303
76
|
|
|
304
|
-
const
|
|
305
|
-
? globalBody.stream_options
|
|
306
|
-
: undefined
|
|
307
|
-
const requestBody: Record<string, unknown> = {
|
|
308
|
-
...globalBody,
|
|
77
|
+
const requestBody = buildOpenAIBody({
|
|
309
78
|
model: modelId,
|
|
79
|
+
messages,
|
|
80
|
+
systemPrompt,
|
|
81
|
+
tools,
|
|
310
82
|
stream: true,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
headers: {
|
|
328
|
-
'Content-Type': 'application/json',
|
|
329
|
-
...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),
|
|
330
|
-
...globalHeaders,
|
|
331
|
-
...provider.headers
|
|
332
|
-
},
|
|
333
|
-
body: JSON.stringify(requestBody)
|
|
334
|
-
})
|
|
335
|
-
} catch (error) {
|
|
336
|
-
if (!signal.aborted) {
|
|
337
|
-
handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
338
|
-
}
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (!response.ok) {
|
|
343
|
-
const text = await response.text().catch(() => '')
|
|
344
|
-
handlers.onError?.(
|
|
345
|
-
new Error(`请求失败(${response.status}):${text || response.statusText}`)
|
|
346
|
-
)
|
|
347
|
-
return
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (!response.body) {
|
|
351
|
-
handlers.onError?.(new Error('响应不包含可读取的流'))
|
|
352
|
-
return
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
await parseSSE(response.body, signal, handlers)
|
|
357
|
-
} catch (error) {
|
|
358
|
-
if (!signal.aborted) {
|
|
359
|
-
handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
360
|
-
}
|
|
361
|
-
}
|
|
83
|
+
reasoningLevel,
|
|
84
|
+
applyReasoning: provider.applyReasoning,
|
|
85
|
+
extraBody: globalBody
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// fetch + SSE 解析 + 错误处理均由通用层完成;错误经 onError 报告,signal 中断静默
|
|
89
|
+
await requestOpenAIStream(
|
|
90
|
+
{
|
|
91
|
+
url: provider.endpoint,
|
|
92
|
+
apiKey: provider.apiKey,
|
|
93
|
+
headers: { ...globalHeaders, ...provider.headers },
|
|
94
|
+
body: requestBody,
|
|
95
|
+
signal
|
|
96
|
+
},
|
|
97
|
+
handlers
|
|
98
|
+
)
|
|
362
99
|
}
|
|
363
100
|
|
|
364
101
|
return Object.assign(send, { models, defaultModel })
|