@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;手写 fetch + SSE,无第三方依赖。
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;手写 fetch + SSE,无第三方依赖。
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 extraStreamOptions = isRecord(globalBody?.stream_options) ? globalBody.stream_options : void 0;
212
- const requestBody = {
213
- ...globalBody,
42
+ const requestBody = buildOpenAIBody({
214
43
  model: modelId,
44
+ messages,
45
+ systemPrompt,
46
+ tools,
215
47
  stream: true,
216
- stream_options: {
217
- ...extraStreamOptions,
218
- include_usage: true
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
- messages: toOpenAIMessages(messages, systemPrompt),
221
- ...tools?.length ? { tools: toOpenAITools(tools) } : {}
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.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.13"
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;手写 fetch + SSE,无第三方依赖。
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 extraStreamOptions = isRecord(globalBody?.stream_options)
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
- // 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
312
- stream_options: { ...extraStreamOptions, include_usage: true },
313
- messages: toOpenAIMessages(messages, systemPrompt),
314
- ...(tools?.length ? { tools: toOpenAITools(tools) } : {})
315
- }
316
-
317
- if (reasoningLevel) {
318
- const apply = provider.applyReasoning ?? defaultApplyReasoning
319
- apply(reasoningLevel, requestBody)
320
- }
321
-
322
- let response: Response
323
- try {
324
- response = await fetch(provider.endpoint, {
325
- method: 'POST',
326
- signal,
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 })