@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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { buildOpenAIBody, parseOpenAICompletionText, parseOpenAISSEStream } from '../openai'
|
|
4
|
+
import type { AiMessage, AiStreamHandlers } from '../types'
|
|
5
|
+
|
|
6
|
+
function baseHandlers(): AiStreamHandlers {
|
|
7
|
+
return { onTextDelta: vi.fn(), onReasoningDelta: vi.fn(), onToolCall: vi.fn(), onUsage: vi.fn() }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 把字符串切成多个网络分片,验证跨 chunk 的 SSE 解析 */
|
|
11
|
+
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
12
|
+
const encoder = new TextEncoder()
|
|
13
|
+
let index = 0
|
|
14
|
+
return new ReadableStream<Uint8Array>({
|
|
15
|
+
pull(controller) {
|
|
16
|
+
if (index < chunks.length) controller.enqueue(encoder.encode(chunks[index++]))
|
|
17
|
+
else controller.close()
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildOpenAIBody', () => {
|
|
23
|
+
it('组装 system / user 消息,缺省 stream=false 且不带 stream_options / tools', () => {
|
|
24
|
+
const body = buildOpenAIBody({
|
|
25
|
+
model: 'm',
|
|
26
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
27
|
+
systemPrompt: 'sys'
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(body).toMatchObject({
|
|
31
|
+
model: 'm',
|
|
32
|
+
stream: false,
|
|
33
|
+
messages: [
|
|
34
|
+
{ role: 'system', content: 'sys' },
|
|
35
|
+
{ role: 'user', content: 'hi' }
|
|
36
|
+
]
|
|
37
|
+
})
|
|
38
|
+
expect(body.stream_options).toBeUndefined()
|
|
39
|
+
expect(body.tools).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('stream=true 时带 stream_options.include_usage,extraBody 展开且同名字段以构造结果为准', () => {
|
|
43
|
+
const body = buildOpenAIBody({
|
|
44
|
+
model: 'm',
|
|
45
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
46
|
+
stream: true,
|
|
47
|
+
extraBody: { temperature: 0.7, model: 'ignored', stream_options: { custom: 1 } }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(body).toMatchObject({
|
|
51
|
+
model: 'm',
|
|
52
|
+
stream: true,
|
|
53
|
+
temperature: 0.7,
|
|
54
|
+
stream_options: { custom: 1, include_usage: true }
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('缺省将 reasoningLevel 写入 reasoning_effort,applyReasoning 可自定义', () => {
|
|
59
|
+
const body = buildOpenAIBody({
|
|
60
|
+
model: 'm',
|
|
61
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
62
|
+
reasoningLevel: 'high'
|
|
63
|
+
})
|
|
64
|
+
expect(body.reasoning_effort).toBe('high')
|
|
65
|
+
|
|
66
|
+
const custom = buildOpenAIBody({
|
|
67
|
+
model: 'm',
|
|
68
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
69
|
+
reasoningLevel: '8k',
|
|
70
|
+
applyReasoning: (level, target) => {
|
|
71
|
+
target.thinking = { budget: level }
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
expect(custom.thinking).toEqual({ budget: '8k' })
|
|
75
|
+
expect(custom.reasoning_effort).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('消息与工具转 OpenAI 格式:附件转 image_url、assistant 带 tool_calls、tool 消息带 tool_call_id', () => {
|
|
79
|
+
const messages: AiMessage[] = [
|
|
80
|
+
{ role: 'user', content: '看图', attachments: [{ dataUrl: 'data:image/png;base64,xxx' }] },
|
|
81
|
+
{ role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'f', arguments: '{}' }] },
|
|
82
|
+
{ role: 'tool', content: 'result', toolCallId: 'c1' }
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const body = buildOpenAIBody({
|
|
86
|
+
model: 'm',
|
|
87
|
+
messages,
|
|
88
|
+
tools: [{ name: 'f', description: 'd', parameters: { type: 'object' } }]
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(body.messages).toEqual([
|
|
92
|
+
{
|
|
93
|
+
role: 'user',
|
|
94
|
+
content: [
|
|
95
|
+
{ type: 'text', text: '看图' },
|
|
96
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,xxx' } }
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
role: 'assistant',
|
|
101
|
+
content: '',
|
|
102
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }]
|
|
103
|
+
},
|
|
104
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'result' }
|
|
105
|
+
])
|
|
106
|
+
expect(body.tools).toEqual([
|
|
107
|
+
{
|
|
108
|
+
type: 'function',
|
|
109
|
+
function: { name: 'f', description: 'd', parameters: { type: 'object' } }
|
|
110
|
+
}
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('parseOpenAISSEStream', () => {
|
|
116
|
+
it('跨网络分片解析 data: 帧,[DONE] 后正常结束', async () => {
|
|
117
|
+
const handlers = baseHandlers()
|
|
118
|
+
await parseOpenAISSEStream(
|
|
119
|
+
sseStream([
|
|
120
|
+
'data: {"choices":[{"delta":{"content":"He',
|
|
121
|
+
'llo"}}]}\n\nda',
|
|
122
|
+
'ta: {"choices":[{"delta":{"content":"!"}}]}\n\ndata: [DONE]\n\n'
|
|
123
|
+
]),
|
|
124
|
+
undefined,
|
|
125
|
+
handlers
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(handlers.onTextDelta).toHaveBeenCalledTimes(2)
|
|
129
|
+
expect(handlers.onTextDelta).toHaveBeenNthCalledWith(1, 'Hello')
|
|
130
|
+
expect(handlers.onTextDelta).toHaveBeenNthCalledWith(2, '!')
|
|
131
|
+
expect(handlers.onUsage).not.toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('兼容 reasoning_content 与 reasoning 两种思考增量字段', async () => {
|
|
135
|
+
const handlers = baseHandlers()
|
|
136
|
+
await parseOpenAISSEStream(
|
|
137
|
+
sseStream([
|
|
138
|
+
'data: {"choices":[{"delta":{"reasoning_content":"想"}}]}\n\n',
|
|
139
|
+
'data: {"choices":[{"delta":{"reasoning":"再想"}}]}\n\n'
|
|
140
|
+
]),
|
|
141
|
+
undefined,
|
|
142
|
+
handlers
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
expect(handlers.onTextDelta).not.toHaveBeenCalled()
|
|
146
|
+
expect(handlers.onReasoningDelta).toHaveBeenNthCalledWith(1, '想')
|
|
147
|
+
expect(handlers.onReasoningDelta).toHaveBeenNthCalledWith(2, '再想')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('解析末包 usage(含缓存命中 / 未命中),无 usage 不回调', async () => {
|
|
151
|
+
const handlers = baseHandlers()
|
|
152
|
+
await parseOpenAISSEStream(
|
|
153
|
+
sseStream([
|
|
154
|
+
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n',
|
|
155
|
+
'data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":8},"prompt_cache_miss_tokens":4}}\n\n',
|
|
156
|
+
'data: [DONE]\n\n'
|
|
157
|
+
]),
|
|
158
|
+
undefined,
|
|
159
|
+
handlers
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
expect(handlers.onUsage).toHaveBeenCalledTimes(1)
|
|
163
|
+
expect(handlers.onUsage).toHaveBeenCalledWith({
|
|
164
|
+
promptTokens: 12,
|
|
165
|
+
completionTokens: 3,
|
|
166
|
+
totalTokens: 15,
|
|
167
|
+
cacheHitTokens: 8,
|
|
168
|
+
cacheMissTokens: 4
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('仅有 cached_tokens 时由 prompt − 命中得到未命中', async () => {
|
|
173
|
+
const handlers = baseHandlers()
|
|
174
|
+
await parseOpenAISSEStream(
|
|
175
|
+
sseStream([
|
|
176
|
+
'data: {"choices":[{"delta":{"content":"a"}}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11,"prompt_tokens_details":{"cached_tokens":7}}}\n\n'
|
|
177
|
+
]),
|
|
178
|
+
undefined,
|
|
179
|
+
handlers
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
expect(handlers.onUsage).toHaveBeenCalledWith({
|
|
183
|
+
promptTokens: 10,
|
|
184
|
+
completionTokens: 1,
|
|
185
|
+
totalTokens: 11,
|
|
186
|
+
cacheHitTokens: 7,
|
|
187
|
+
cacheMissTokens: 3
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('tool_calls 参数分片跨 chunk 累积,多个调用按 index 顺序完整抛出', async () => {
|
|
192
|
+
const handlers = baseHandlers()
|
|
193
|
+
await parseOpenAISSEStream(
|
|
194
|
+
sseStream([
|
|
195
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"alpha","arguments":""}}]}}]}\n\n',
|
|
196
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"x\\":1}"}}]}}]}\n\n',
|
|
197
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"beta","arguments":"{\\"y\\":2}"}}]}}]}\n\n',
|
|
198
|
+
'data: [DONE]\n\n'
|
|
199
|
+
]),
|
|
200
|
+
undefined,
|
|
201
|
+
handlers
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
expect(handlers.onToolCall).toHaveBeenCalledTimes(2)
|
|
205
|
+
expect(handlers.onToolCall).toHaveBeenNthCalledWith(1, {
|
|
206
|
+
id: 'call_1',
|
|
207
|
+
name: 'alpha',
|
|
208
|
+
arguments: '{"x":1}'
|
|
209
|
+
})
|
|
210
|
+
expect(handlers.onToolCall).toHaveBeenNthCalledWith(2, {
|
|
211
|
+
id: 'call_2',
|
|
212
|
+
name: 'beta',
|
|
213
|
+
arguments: '{"y":2}'
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('signal 已中断时不再处理后续分片', async () => {
|
|
218
|
+
const handlers = baseHandlers()
|
|
219
|
+
const controller = new AbortController()
|
|
220
|
+
controller.abort()
|
|
221
|
+
|
|
222
|
+
await parseOpenAISSEStream(
|
|
223
|
+
sseStream(['data: {"choices":[{"delta":{"content":"no"}}]}\n\n']),
|
|
224
|
+
controller.signal,
|
|
225
|
+
handlers
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(handlers.onTextDelta).not.toHaveBeenCalled()
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('parseOpenAICompletionText', () => {
|
|
233
|
+
it('取 choices[0].message.content 作为完整文本', () => {
|
|
234
|
+
expect(
|
|
235
|
+
parseOpenAICompletionText({ choices: [{ message: { role: 'assistant', content: '答案' } }] })
|
|
236
|
+
).toBe('答案')
|
|
237
|
+
expect(parseOpenAICompletionText({ choices: [{ message: { content: '' } }] })).toBe('')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('形状不含可解析文本时返回 undefined', () => {
|
|
241
|
+
expect(parseOpenAICompletionText({ choices: [] })).toBeUndefined()
|
|
242
|
+
expect(parseOpenAICompletionText({ choices: [{ message: {} }] })).toBeUndefined()
|
|
243
|
+
expect(parseOpenAICompletionText('oops')).toBeUndefined()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { buildOpenAIBody } from '../openai'
|
|
4
|
+
import { requestAiText, requestOpenAIStream } from '../request'
|
|
5
|
+
import type { AiProviderConfig } from '../types'
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals()
|
|
9
|
+
vi.restoreAllMocks()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function config(overrides: Partial<AiProviderConfig> = {}): AiProviderConfig {
|
|
13
|
+
return {
|
|
14
|
+
url: 'https://api.example.com/v1/chat/completions',
|
|
15
|
+
apiKey: 'sk-test',
|
|
16
|
+
model: 'gpt-x',
|
|
17
|
+
...overrides
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sseResponse(sse: string): Response {
|
|
22
|
+
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('requestAiText', () => {
|
|
26
|
+
it('流式:增量逐段触发 onDelta,结束后以拼接文本触发 onSuccess,请求体带 system/user 消息与 reasoning_effort', async () => {
|
|
27
|
+
const sse =
|
|
28
|
+
'data: {"choices":[{"delta":{"content":"He"}}]}\n\n' +
|
|
29
|
+
'data: {"choices":[{"delta":{"content":"llo"}}]}\n\n' +
|
|
30
|
+
'data: [DONE]\n\n'
|
|
31
|
+
const fetchMock = vi.fn(async () => sseResponse(sse))
|
|
32
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
33
|
+
|
|
34
|
+
const onDelta = vi.fn()
|
|
35
|
+
const onSuccess = vi.fn()
|
|
36
|
+
const onError = vi.fn()
|
|
37
|
+
await requestAiText({
|
|
38
|
+
config: config({ stream: true, reasoningLevel: 'high' }),
|
|
39
|
+
prompt: 'hi',
|
|
40
|
+
systemPrompt: 'sys',
|
|
41
|
+
onDelta,
|
|
42
|
+
onSuccess,
|
|
43
|
+
onError
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(onDelta).toHaveBeenNthCalledWith(1, 'He')
|
|
47
|
+
expect(onDelta).toHaveBeenNthCalledWith(2, 'llo')
|
|
48
|
+
expect(onSuccess).toHaveBeenCalledWith('Hello')
|
|
49
|
+
expect(onError).not.toHaveBeenCalled()
|
|
50
|
+
|
|
51
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
52
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
53
|
+
expect(url).toBe('https://api.example.com/v1/chat/completions')
|
|
54
|
+
expect(init!.method).toBe('POST')
|
|
55
|
+
expect(init!.headers).toMatchObject({
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
Authorization: 'Bearer sk-test'
|
|
58
|
+
})
|
|
59
|
+
expect(JSON.parse(String(init!.body))).toMatchObject({
|
|
60
|
+
model: 'gpt-x',
|
|
61
|
+
stream: true,
|
|
62
|
+
stream_options: { include_usage: true },
|
|
63
|
+
reasoning_effort: 'high',
|
|
64
|
+
messages: [
|
|
65
|
+
{ role: 'system', content: 'sys' },
|
|
66
|
+
{ role: 'user', content: 'hi' }
|
|
67
|
+
]
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('非流式(缺省 stream=false):解析 JSON 响应为完整文本,不触发 onDelta', async () => {
|
|
72
|
+
const fetchMock = vi.fn(
|
|
73
|
+
async () =>
|
|
74
|
+
new Response(
|
|
75
|
+
JSON.stringify({ choices: [{ message: { role: 'assistant', content: '完整答案' } }] }),
|
|
76
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
80
|
+
|
|
81
|
+
const onDelta = vi.fn()
|
|
82
|
+
const onSuccess = vi.fn()
|
|
83
|
+
const onError = vi.fn()
|
|
84
|
+
await requestAiText({ config: config(), prompt: 'q', onDelta, onSuccess, onError })
|
|
85
|
+
|
|
86
|
+
expect(onDelta).not.toHaveBeenCalled()
|
|
87
|
+
expect(onSuccess).toHaveBeenCalledWith('完整答案')
|
|
88
|
+
expect(onError).not.toHaveBeenCalled()
|
|
89
|
+
|
|
90
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))
|
|
91
|
+
expect(body).toMatchObject({
|
|
92
|
+
model: 'gpt-x',
|
|
93
|
+
stream: false,
|
|
94
|
+
messages: [{ role: 'user', content: 'q' }]
|
|
95
|
+
})
|
|
96
|
+
expect(body.stream_options).toBeUndefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('非流式响应缺可解析文本或非法 JSON 时触发 onError', async () => {
|
|
100
|
+
vi.stubGlobal(
|
|
101
|
+
'fetch',
|
|
102
|
+
vi
|
|
103
|
+
.fn()
|
|
104
|
+
.mockResolvedValueOnce(
|
|
105
|
+
new Response(JSON.stringify({ choices: [{ message: {} }] }), { status: 200 })
|
|
106
|
+
)
|
|
107
|
+
.mockResolvedValueOnce(new Response('not json', { status: 200 }))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const onError = vi.fn()
|
|
111
|
+
await requestAiText({ config: config(), prompt: 'q', onError })
|
|
112
|
+
expect(onError).toHaveBeenNthCalledWith(
|
|
113
|
+
1,
|
|
114
|
+
expect.objectContaining({ message: expect.stringContaining('文本内容') })
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await requestAiText({ config: config(), prompt: 'q', onError })
|
|
118
|
+
expect(onError).toHaveBeenNthCalledWith(2, expect.any(Error))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('非 2xx 触发 onError(含状态码与响应体),不触发 onSuccess', async () => {
|
|
122
|
+
vi.stubGlobal(
|
|
123
|
+
'fetch',
|
|
124
|
+
vi.fn(async () => new Response('rate limited', { status: 429 }))
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const onSuccess = vi.fn()
|
|
128
|
+
const onError = vi.fn()
|
|
129
|
+
await requestAiText({ config: config(), prompt: 'q', onSuccess, onError })
|
|
130
|
+
|
|
131
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(onError).toHaveBeenCalledWith(
|
|
133
|
+
expect.objectContaining({ message: expect.stringMatching(/429.*rate limited/) })
|
|
134
|
+
)
|
|
135
|
+
expect(onSuccess).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('网络错误触发 onError;signal 中断时静默(不触发任何回调)', async () => {
|
|
139
|
+
vi.stubGlobal(
|
|
140
|
+
'fetch',
|
|
141
|
+
vi.fn(async () => {
|
|
142
|
+
throw new TypeError('fetch failed')
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const onError = vi.fn()
|
|
147
|
+
await requestAiText({ config: config({ stream: true }), prompt: 'hi', onError })
|
|
148
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
149
|
+
expect(onError).toHaveBeenCalledWith(new TypeError('fetch failed'))
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
controller.abort()
|
|
153
|
+
const silent = { onError: vi.fn(), onSuccess: vi.fn(), onDelta: vi.fn() }
|
|
154
|
+
await requestAiText({
|
|
155
|
+
config: config({ stream: true }),
|
|
156
|
+
prompt: 'hi',
|
|
157
|
+
signal: controller.signal,
|
|
158
|
+
...silent
|
|
159
|
+
})
|
|
160
|
+
expect(silent.onError).not.toHaveBeenCalled()
|
|
161
|
+
expect(silent.onSuccess).not.toHaveBeenCalled()
|
|
162
|
+
expect(silent.onDelta).not.toHaveBeenCalled()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('无 apiKey 不带 Authorization 头', async () => {
|
|
166
|
+
const fetchMock = vi.fn(async () => sseResponse('data: [DONE]\n\n'))
|
|
167
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
168
|
+
|
|
169
|
+
await requestAiText({ config: config({ apiKey: undefined }), prompt: 'hi' })
|
|
170
|
+
|
|
171
|
+
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>
|
|
172
|
+
expect(headers.Authorization).toBeUndefined()
|
|
173
|
+
expect(headers['Content-Type']).toBe('application/json')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('requestOpenAIStream', () => {
|
|
178
|
+
function streamBody(): Record<string, unknown> {
|
|
179
|
+
return buildOpenAIBody({
|
|
180
|
+
model: 'm',
|
|
181
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
182
|
+
stream: true
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it('流完整读取返回 true,额外请求头合并、无 apiKey 不带 Authorization', async () => {
|
|
187
|
+
const fetchMock = vi.fn(async () =>
|
|
188
|
+
sseResponse('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
|
|
189
|
+
)
|
|
190
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
191
|
+
|
|
192
|
+
const handlers = { onTextDelta: vi.fn(), onError: vi.fn() }
|
|
193
|
+
const completed = await requestOpenAIStream(
|
|
194
|
+
{ url: '/api/chat', body: streamBody(), headers: { 'X-Trace': '1' } },
|
|
195
|
+
handlers
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(completed).toBe(true)
|
|
199
|
+
expect(handlers.onTextDelta).toHaveBeenCalledWith('ok')
|
|
200
|
+
expect(handlers.onError).not.toHaveBeenCalled()
|
|
201
|
+
|
|
202
|
+
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>
|
|
203
|
+
expect(headers.Authorization).toBeUndefined()
|
|
204
|
+
expect(headers['X-Trace']).toBe('1')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('响应无 body 时经 onError 报错并返回 false', async () => {
|
|
208
|
+
vi.stubGlobal(
|
|
209
|
+
'fetch',
|
|
210
|
+
vi.fn(async () => ({ ok: true, status: 200, body: null }) as unknown as Response)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const handlers = { onTextDelta: vi.fn(), onError: vi.fn() }
|
|
214
|
+
const completed = await requestOpenAIStream({ url: '/api/chat', body: streamBody() }, handlers)
|
|
215
|
+
|
|
216
|
+
expect(completed).toBe(false)
|
|
217
|
+
expect(handlers.onError).toHaveBeenCalledWith(new Error('响应不包含可读取的流'))
|
|
218
|
+
})
|
|
219
|
+
})
|
package/src/ai/index.ts
ADDED