@veltra/utils 1.9.2 → 1.11.0
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/ai/openai.d.ts +40 -0
- package/dist/ai/openai.js +211 -0
- package/dist/ai/openai.js.map +1 -0
- package/dist/ai/request.d.ts +37 -0
- package/dist/ai/request.js +112 -0
- package/dist/ai/request.js.map +1 -0
- package/dist/ai/types.d.ts +109 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/package.json +1 -1
- package/src/ai/__test__/openai.test.ts +245 -0
- package/src/ai/__test__/request.test.ts +219 -0
- package/src/ai/index.ts +3 -0
- package/src/ai/openai.ts +290 -0
- package/src/ai/request.ts +147 -0
- package/src/ai/types.ts +110 -0
- package/src/index.ts +2 -0
package/src/ai/openai.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { AiMessage, AiStreamHandlers, AiTokenUsage, AiTool } from './types'
|
|
2
|
+
|
|
3
|
+
/** 请求体构造选项 */
|
|
4
|
+
export interface OpenAIBodyOptions {
|
|
5
|
+
/** 模型 id */
|
|
6
|
+
model: string
|
|
7
|
+
/** 消息历史(内部消息模型,本函数转换为 wire 格式) */
|
|
8
|
+
messages: readonly AiMessage[]
|
|
9
|
+
/** 系统提示词,存在时作为首条 system 消息 */
|
|
10
|
+
systemPrompt?: string
|
|
11
|
+
/** 可用工具 */
|
|
12
|
+
tools?: readonly AiTool[]
|
|
13
|
+
/** 是否流式,缺省 false */
|
|
14
|
+
stream?: boolean
|
|
15
|
+
/** 推理等级(不透明字符串,由 applyReasoning 写入请求体) */
|
|
16
|
+
reasoningLevel?: string
|
|
17
|
+
/** 自定义推理等级写入(缺省写 OpenAI 兼容的 reasoning_effort) */
|
|
18
|
+
applyReasoning?(level: string, body: Record<string, unknown>): void
|
|
19
|
+
/** 额外请求体字段(展开在最前,同名字段以本函数写入的为准) */
|
|
20
|
+
extraBody?: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** OpenAI 兼容 wire 消息格式 */
|
|
24
|
+
interface OpenAIMessage {
|
|
25
|
+
role: string
|
|
26
|
+
content?: unknown
|
|
27
|
+
tool_calls?: unknown[]
|
|
28
|
+
tool_call_id?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toOpenAIMessages(messages: readonly AiMessage[], systemPrompt?: string): OpenAIMessage[] {
|
|
32
|
+
const result: OpenAIMessage[] = []
|
|
33
|
+
|
|
34
|
+
if (systemPrompt) {
|
|
35
|
+
result.push({ role: 'system', content: systemPrompt })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const msg of messages) {
|
|
39
|
+
if (msg.role === 'user') {
|
|
40
|
+
if (msg.attachments?.length) {
|
|
41
|
+
const parts: unknown[] = []
|
|
42
|
+
if (msg.content) parts.push({ type: 'text', text: msg.content })
|
|
43
|
+
for (const att of msg.attachments) {
|
|
44
|
+
parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })
|
|
45
|
+
}
|
|
46
|
+
result.push({ role: 'user', content: parts })
|
|
47
|
+
} else {
|
|
48
|
+
result.push({ role: 'user', content: msg.content })
|
|
49
|
+
}
|
|
50
|
+
} else if (msg.role === 'assistant') {
|
|
51
|
+
const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }
|
|
52
|
+
if (msg.toolCalls?.length) {
|
|
53
|
+
item.tool_calls = msg.toolCalls.map((call) => ({
|
|
54
|
+
id: call.id,
|
|
55
|
+
type: 'function',
|
|
56
|
+
function: { name: call.name, arguments: call.arguments }
|
|
57
|
+
}))
|
|
58
|
+
}
|
|
59
|
+
result.push(item)
|
|
60
|
+
} else if (msg.role === 'tool') {
|
|
61
|
+
result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toOpenAITools(tools: readonly AiTool[]): unknown[] {
|
|
69
|
+
return tools.map((tool) => ({
|
|
70
|
+
type: 'function',
|
|
71
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters }
|
|
72
|
+
}))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 缺省:写入 OpenAI 兼容的 reasoning_effort */
|
|
76
|
+
function defaultApplyReasoning(level: string, body: Record<string, unknown>) {
|
|
77
|
+
body.reasoning_effort = level
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
81
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
|
|
85
|
+
function readCount(value: unknown): number | undefined {
|
|
86
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
|
|
87
|
+
return Math.floor(value)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 从 OpenAI 兼容 usage 对象解析。
|
|
92
|
+
* 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
|
|
93
|
+
*/
|
|
94
|
+
function parseOpenAIUsage(raw: unknown): AiTokenUsage | null {
|
|
95
|
+
if (!isRecord(raw)) return null
|
|
96
|
+
|
|
97
|
+
const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
|
|
98
|
+
const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
|
|
99
|
+
if (promptTokens == null && completionTokens == null) return null
|
|
100
|
+
|
|
101
|
+
const prompt = promptTokens ?? 0
|
|
102
|
+
const completion = completionTokens ?? 0
|
|
103
|
+
const usage: AiTokenUsage = {
|
|
104
|
+
promptTokens: prompt,
|
|
105
|
+
completionTokens: completion,
|
|
106
|
+
totalTokens: readCount(raw.total_tokens) ?? prompt + completion
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
|
|
110
|
+
const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
|
|
111
|
+
const cacheHitTokens =
|
|
112
|
+
readCount(details?.cached_tokens) ??
|
|
113
|
+
readCount(inputDetails?.cached_tokens) ??
|
|
114
|
+
readCount(raw.prompt_cache_hit_tokens) ??
|
|
115
|
+
readCount(raw.cache_read_input_tokens) ??
|
|
116
|
+
readCount(raw.cached_tokens)
|
|
117
|
+
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
118
|
+
|
|
119
|
+
const cacheMissTokens =
|
|
120
|
+
readCount(raw.prompt_cache_miss_tokens) ??
|
|
121
|
+
(cacheHitTokens != null && promptTokens != null
|
|
122
|
+
? Math.max(0, promptTokens - cacheHitTokens)
|
|
123
|
+
: undefined)
|
|
124
|
+
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
125
|
+
|
|
126
|
+
const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
|
|
127
|
+
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
128
|
+
|
|
129
|
+
return usage
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface AccumulatedToolCall {
|
|
133
|
+
id: string
|
|
134
|
+
name: string
|
|
135
|
+
arguments: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 构造 OpenAI 兼容 chat/completions 请求体。
|
|
140
|
+
* 流式时写入 `stream_options.include_usage` 以获取末包 usage。
|
|
141
|
+
*/
|
|
142
|
+
export function buildOpenAIBody(options: OpenAIBodyOptions): Record<string, unknown> {
|
|
143
|
+
const { model, messages, systemPrompt, tools, reasoningLevel, applyReasoning, extraBody } =
|
|
144
|
+
options
|
|
145
|
+
const stream = options.stream ?? false
|
|
146
|
+
// 额外字段里的 stream_options 与 include_usage 合并,额外字段优先的部分仍保留
|
|
147
|
+
const extraStreamOptions = isRecord(extraBody?.stream_options)
|
|
148
|
+
? extraBody.stream_options
|
|
149
|
+
: undefined
|
|
150
|
+
|
|
151
|
+
const body: Record<string, unknown> = {
|
|
152
|
+
...extraBody,
|
|
153
|
+
model,
|
|
154
|
+
stream,
|
|
155
|
+
// 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
|
|
156
|
+
...(stream ? { stream_options: { ...extraStreamOptions, include_usage: true } } : {}),
|
|
157
|
+
messages: toOpenAIMessages(messages, systemPrompt),
|
|
158
|
+
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (reasoningLevel) {
|
|
162
|
+
const apply = applyReasoning ?? defaultApplyReasoning
|
|
163
|
+
apply(reasoningLevel, body)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return body
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 逐行解析 OpenAI 兼容 SSE 流(`data:` 帧、`[DONE]`、content / reasoning 增量、
|
|
171
|
+
* tool_calls 分片累积、usage 解析)。
|
|
172
|
+
* 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
|
|
173
|
+
* 读流抛出的异常原样向外传播,由调用方决定错误处理;signal 中断即停止读取。
|
|
174
|
+
*/
|
|
175
|
+
export async function parseOpenAISSEStream(
|
|
176
|
+
body: ReadableStream<Uint8Array>,
|
|
177
|
+
signal: AbortSignal | undefined,
|
|
178
|
+
handlers: AiStreamHandlers
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const reader = body.getReader()
|
|
181
|
+
const decoder = new TextDecoder()
|
|
182
|
+
let buffer = ''
|
|
183
|
+
|
|
184
|
+
// 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段
|
|
185
|
+
const accumulated = new Map<number, AccumulatedToolCall>()
|
|
186
|
+
let lastIndex = -1
|
|
187
|
+
|
|
188
|
+
const flushToolCalls = () => {
|
|
189
|
+
if (!handlers.onToolCall) return
|
|
190
|
+
for (const call of accumulated.values()) {
|
|
191
|
+
if (call.id && call.name) {
|
|
192
|
+
handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
accumulated.clear()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const handleData = (data: string) => {
|
|
199
|
+
if (data === '[DONE]') return
|
|
200
|
+
|
|
201
|
+
let chunk: unknown
|
|
202
|
+
try {
|
|
203
|
+
chunk = JSON.parse(data)
|
|
204
|
+
} catch {
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (!isRecord(chunk)) return
|
|
208
|
+
|
|
209
|
+
const usage = parseOpenAIUsage(chunk.usage)
|
|
210
|
+
if (usage) handlers.onUsage?.(usage)
|
|
211
|
+
|
|
212
|
+
const choices = chunk.choices
|
|
213
|
+
const first = Array.isArray(choices) ? choices[0] : undefined
|
|
214
|
+
const delta = isRecord(first) ? first.delta : undefined
|
|
215
|
+
if (!isRecord(delta)) return
|
|
216
|
+
|
|
217
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
218
|
+
handlers.onTextDelta(delta.content)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)
|
|
222
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning
|
|
223
|
+
if (typeof reasoning === 'string' && reasoning) {
|
|
224
|
+
handlers.onReasoningDelta?.(reasoning)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
228
|
+
for (const tc of delta.tool_calls) {
|
|
229
|
+
if (!isRecord(tc)) continue
|
|
230
|
+
const index = typeof tc.index === 'number' ? tc.index : 0
|
|
231
|
+
// 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
|
|
232
|
+
if (index > lastIndex && accumulated.size > 0) flushToolCalls()
|
|
233
|
+
lastIndex = Math.max(lastIndex, index)
|
|
234
|
+
|
|
235
|
+
let call = accumulated.get(index)
|
|
236
|
+
if (!call) {
|
|
237
|
+
call = { id: '', name: '', arguments: '' }
|
|
238
|
+
accumulated.set(index, call)
|
|
239
|
+
}
|
|
240
|
+
if (typeof tc.id === 'string') call.id = tc.id
|
|
241
|
+
const fn = isRecord(tc.function) ? tc.function : undefined
|
|
242
|
+
if (typeof fn?.name === 'string') call.name += fn.name
|
|
243
|
+
if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// 递归泵取代替 while 循环,避免 await-in-loop
|
|
250
|
+
const pump = async (): Promise<void> => {
|
|
251
|
+
const { done, value } = await reader.read()
|
|
252
|
+
if (done || signal?.aborted) return
|
|
253
|
+
|
|
254
|
+
buffer += decoder.decode(value, { stream: true })
|
|
255
|
+
|
|
256
|
+
let newlineIndex: number
|
|
257
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
258
|
+
const line = buffer.slice(0, newlineIndex).trim()
|
|
259
|
+
buffer = buffer.slice(newlineIndex + 1)
|
|
260
|
+
if (line.startsWith('data:')) {
|
|
261
|
+
handleData(line.slice(5).trim())
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return pump()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await pump()
|
|
269
|
+
|
|
270
|
+
if (buffer.trim().startsWith('data:')) {
|
|
271
|
+
handleData(buffer.trim().slice(5).trim())
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
reader.releaseLock()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
flushToolCalls()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 从非流式 chat/completions JSON 响应取完整回复文本。
|
|
282
|
+
* 响应形状不含可解析文本时返回 undefined。
|
|
283
|
+
*/
|
|
284
|
+
export function parseOpenAICompletionText(json: unknown): string | undefined {
|
|
285
|
+
if (!isRecord(json)) return undefined
|
|
286
|
+
const choices = json.choices
|
|
287
|
+
const first = Array.isArray(choices) ? choices[0] : undefined
|
|
288
|
+
const message = isRecord(first) && isRecord(first.message) ? first.message : undefined
|
|
289
|
+
return typeof message?.content === 'string' ? message.content : undefined
|
|
290
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { buildOpenAIBody, parseOpenAICompletionText, parseOpenAISSEStream } from './openai'
|
|
2
|
+
import type { AiStreamHandlers, AiTextRequestOptions } from './types'
|
|
3
|
+
|
|
4
|
+
/** OpenAI 兼容请求的发送选项(body 用 buildOpenAIBody 构造) */
|
|
5
|
+
export interface OpenAIRequestOptions {
|
|
6
|
+
/** OpenAI 兼容 chat/completions 完整请求地址 */
|
|
7
|
+
url: string
|
|
8
|
+
/** Bearer Token,缺省不带 Authorization 头 */
|
|
9
|
+
apiKey?: string
|
|
10
|
+
/** 额外请求头(覆盖同名默认头) */
|
|
11
|
+
headers?: Record<string, string>
|
|
12
|
+
/** 请求体 */
|
|
13
|
+
body: Record<string, unknown>
|
|
14
|
+
/** 中断信号 */
|
|
15
|
+
signal?: AbortSignal
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** OpenAI 兼容流式请求回调 */
|
|
19
|
+
export interface OpenAIStreamCallbacks extends AiStreamHandlers {
|
|
20
|
+
/** 请求错误(网络错误、非 2xx、流中断);signal 中断时静默 */
|
|
21
|
+
onError?(error: Error): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 发送 OpenAI 兼容 POST 并统一错误处理。
|
|
26
|
+
* 返回 null 表示失败(错误已报给 onError)或已中断(静默)。
|
|
27
|
+
*/
|
|
28
|
+
async function postOpenAI(
|
|
29
|
+
options: OpenAIRequestOptions,
|
|
30
|
+
onError?: (error: Error) => void
|
|
31
|
+
): Promise<Response | null> {
|
|
32
|
+
const { url, apiKey, headers, body, signal } = options
|
|
33
|
+
|
|
34
|
+
let response: Response
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
signal,
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
42
|
+
...headers
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify(body)
|
|
45
|
+
})
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!signal?.aborted) {
|
|
48
|
+
onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const text = await response.text().catch(() => '')
|
|
55
|
+
onError?.(new Error(`请求失败(${response.status}):${text || response.statusText}`))
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* OpenAI 兼容流式请求:fetch + SSE 解析。
|
|
64
|
+
* 手写 fetch,无第三方依赖;错误经 onError 报告,signal 中断静默。
|
|
65
|
+
*
|
|
66
|
+
* @returns 流是否完整读取(false 表示失败或已中断)
|
|
67
|
+
*/
|
|
68
|
+
export async function requestOpenAIStream(
|
|
69
|
+
options: OpenAIRequestOptions,
|
|
70
|
+
callbacks: OpenAIStreamCallbacks
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
const response = await postOpenAI(options, callbacks.onError)
|
|
73
|
+
if (!response) return false
|
|
74
|
+
|
|
75
|
+
if (!response.body) {
|
|
76
|
+
callbacks.onError?.(new Error('响应不包含可读取的流'))
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await parseOpenAISSEStream(response.body, options.signal, callbacks)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!options.signal?.aborted) {
|
|
84
|
+
callbacks.onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 面向组件的单配置请求入口。
|
|
94
|
+
* 用 `systemPrompt`(可选)+ `prompt` 组装 messages,按 `config.stream` 走流式 / 非流式:
|
|
95
|
+
* - 流式:增量逐段触发 onDelta,流完整结束后以拼接文本触发 onSuccess;
|
|
96
|
+
* - 非流式:解析 JSON 响应,以完整文本触发 onSuccess。
|
|
97
|
+
* 失败触发 onError;signal 中断不触发任何回调(静默)。
|
|
98
|
+
*/
|
|
99
|
+
export async function requestAiText(options: AiTextRequestOptions): Promise<void> {
|
|
100
|
+
const { config, prompt, systemPrompt, signal, onDelta, onSuccess, onError } = options
|
|
101
|
+
|
|
102
|
+
const body = buildOpenAIBody({
|
|
103
|
+
model: config.model,
|
|
104
|
+
messages: [{ role: 'user', content: prompt }],
|
|
105
|
+
systemPrompt,
|
|
106
|
+
stream: config.stream ?? false,
|
|
107
|
+
reasoningLevel: config.reasoningLevel
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if (config.stream) {
|
|
111
|
+
let text = ''
|
|
112
|
+
const completed = await requestOpenAIStream(
|
|
113
|
+
{ url: config.url, apiKey: config.apiKey, body, signal },
|
|
114
|
+
{
|
|
115
|
+
onTextDelta(delta) {
|
|
116
|
+
text += delta
|
|
117
|
+
onDelta?.(delta)
|
|
118
|
+
},
|
|
119
|
+
onError
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
if (completed) onSuccess?.(text)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const response = await postOpenAI(
|
|
127
|
+
{ url: config.url, apiKey: config.apiKey, body, signal },
|
|
128
|
+
onError
|
|
129
|
+
)
|
|
130
|
+
if (!response) return
|
|
131
|
+
|
|
132
|
+
let json: unknown
|
|
133
|
+
try {
|
|
134
|
+
json = await response.json()
|
|
135
|
+
} catch (error) {
|
|
136
|
+
onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const text = parseOpenAICompletionText(json)
|
|
141
|
+
if (text == null) {
|
|
142
|
+
onError?.(new Error('响应不包含可解析的文本内容'))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onSuccess?.(text)
|
|
147
|
+
}
|
package/src/ai/types.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 服务商配置(OpenAI 兼容 chat/completions)。
|
|
3
|
+
* 组件实例 prop 与全局配置(useConfig 的 ai 键)字段级合并后使用。
|
|
4
|
+
*/
|
|
5
|
+
export interface AiProviderConfig {
|
|
6
|
+
/** OpenAI 兼容 chat/completions 的完整请求地址(语义同 @veltra/ai 的 endpoint) */
|
|
7
|
+
url: string
|
|
8
|
+
/** Bearer Token,缺省不带 Authorization 头 */
|
|
9
|
+
apiKey?: string
|
|
10
|
+
/** 模型 id */
|
|
11
|
+
model: string
|
|
12
|
+
/** 推理等级;缺省不写入请求体,提供时默认写 reasoning_effort */
|
|
13
|
+
reasoningLevel?: string
|
|
14
|
+
/** 是否流式返回,缺省 false(一次性返回完整文本) */
|
|
15
|
+
stream?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 一次完整的工具调用(SSE 参数分片在内部累积完整后抛出) */
|
|
19
|
+
export interface AiToolCall {
|
|
20
|
+
/** 调用 id(由模型生成) */
|
|
21
|
+
id: string
|
|
22
|
+
/** 工具名 */
|
|
23
|
+
name: string
|
|
24
|
+
/** 模型输出的原始 JSON 参数串 */
|
|
25
|
+
arguments: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 请求侧消息的最小结构(内部消息模型转 wire 格式的入参形状)。
|
|
30
|
+
* @veltra/ai 的 ChatMessage 满足该形状,可直接传入。
|
|
31
|
+
*/
|
|
32
|
+
export interface AiMessage {
|
|
33
|
+
role: 'user' | 'assistant' | 'tool'
|
|
34
|
+
content: string
|
|
35
|
+
/** 用户消息图片附件(转 OpenAI image_url 内容分片) */
|
|
36
|
+
attachments?: ReadonlyArray<{ dataUrl: string }>
|
|
37
|
+
/** assistant 消息上的工具调用列表 */
|
|
38
|
+
toolCalls?: ReadonlyArray<AiToolCall>
|
|
39
|
+
/** role 为 tool 时关联的工具调用 id */
|
|
40
|
+
toolCallId?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 请求侧工具定义的最小结构。
|
|
45
|
+
* @veltra/ai 的 ChatTool 满足该形状,可直接传入。
|
|
46
|
+
*/
|
|
47
|
+
export interface AiTool {
|
|
48
|
+
/** 工具名(传给模型,需唯一) */
|
|
49
|
+
name: string
|
|
50
|
+
/** 工具描述(传给模型) */
|
|
51
|
+
description: string
|
|
52
|
+
/** 参数 JSON Schema(原样传给模型) */
|
|
53
|
+
parameters: Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 一次模型请求的 token 用量。
|
|
58
|
+
* 仅包含接口实际返回的字段;缓存类数字缺省为 undefined,不补 0。
|
|
59
|
+
*/
|
|
60
|
+
export interface AiTokenUsage {
|
|
61
|
+
/** 输入 token */
|
|
62
|
+
promptTokens: number
|
|
63
|
+
/** 输出 token */
|
|
64
|
+
completionTokens: number
|
|
65
|
+
/** 合计 token */
|
|
66
|
+
totalTokens: number
|
|
67
|
+
/**
|
|
68
|
+
* 缓存命中。
|
|
69
|
+
* 来源:`prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
|
|
70
|
+
* `cache_read_input_tokens` / `cached_tokens`。
|
|
71
|
+
*/
|
|
72
|
+
cacheHitTokens?: number
|
|
73
|
+
/**
|
|
74
|
+
* 缓存未命中。
|
|
75
|
+
* 来源:`prompt_cache_miss_tokens`;若无该字段但有命中与 prompt,则为 prompt − 命中。
|
|
76
|
+
*/
|
|
77
|
+
cacheMissTokens?: number
|
|
78
|
+
/** 写入缓存(Anthropic 风格 `cache_creation_input_tokens`),有才有 */
|
|
79
|
+
cacheCreationTokens?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** OpenAI 兼容 SSE 流事件回调(请求错误由请求层统一处理,不经此抛出) */
|
|
83
|
+
export interface AiStreamHandlers {
|
|
84
|
+
/** 文本增量 */
|
|
85
|
+
onTextDelta(delta: string): void
|
|
86
|
+
/** 思考内容增量 */
|
|
87
|
+
onReasoningDelta?(delta: string): void
|
|
88
|
+
/** 一次完整的工具调用(流式参数片段在内部累积完整后才抛出) */
|
|
89
|
+
onToolCall?(call: AiToolCall): void
|
|
90
|
+
/** 本次请求的 token 用量(流式末包);接口未返回时不调用,不填 0 充数 */
|
|
91
|
+
onUsage?(usage: AiTokenUsage): void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** requestAiText 的入参:面向组件的单配置请求 */
|
|
95
|
+
export interface AiTextRequestOptions {
|
|
96
|
+
/** 服务商配置(url / apiKey / model / reasoningLevel / stream) */
|
|
97
|
+
config: AiProviderConfig
|
|
98
|
+
/** 用户提示词(作为 user 消息) */
|
|
99
|
+
prompt: string
|
|
100
|
+
/** 系统提示词,存在时作为首条 system 消息 */
|
|
101
|
+
systemPrompt?: string
|
|
102
|
+
/** 中断信号;中断后不触发任何回调(静默) */
|
|
103
|
+
signal?: AbortSignal
|
|
104
|
+
/** 文本增量,仅流式时逐段触发 */
|
|
105
|
+
onDelta?(delta: string): void
|
|
106
|
+
/** 成功:流式为全部增量拼接,非流式为完整响应文本 */
|
|
107
|
+
onSuccess?(text: string): void
|
|
108
|
+
/** 失败:网络错误、非 2xx 响应、流中断 */
|
|
109
|
+
onError?(error: Error): void
|
|
110
|
+
}
|