http.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import { HttpEmbeddingProvider, parseRetryAfter } from '../src/http'
  5. import { resolveSettings, type EmbeddingSettings } from '../src/config'
  6. const cleanups: Array<() => Promise<void>> = []
  7. afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() })
  8. async function fixture(handler: (body: any, req: IncomingMessage, res: ServerResponse, call: number) => void) {
  9. let calls = 0
  10. const server = createServer((req, res) => {
  11. void (async () => {
  12. let text = ''
  13. for await (const part of req) text += part
  14. calls++
  15. handler(text ? JSON.parse(text) : undefined, req, res, calls)
  16. })().catch(() => res.destroy())
  17. })
  18. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  19. cleanups.push(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) })
  20. return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`, calls: () => calls }
  21. }
  22. const json = (response: ServerResponse, body: unknown) => { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify(body)) }
  23. function provider(url: string, options: EmbeddingSettings = {}, key: () => Promise<string | undefined> = async () => 'fixture-key') {
  24. const client = new HttpEmbeddingProvider(resolveSettings({ enabled: true, baseURL: url, model: 'fixture-model', dimensions: 3, maxRetries: 0, ...options })!, key)
  25. cleanups.push(() => client.close())
  26. return client
  27. }
  28. const texts = [{ text: 'alpha', title: '第1章' }, { text: 'beta', title: '第2章' }]
  29. describe('外部嵌入协议(真实本地 HTTP)', () => {
  30. it('后台单批仅发起一次请求,保留 HTTP 状态及 Retry-After', async () => {
  31. const api = await fixture((_body, _req, res) => {
  32. res.statusCode = 429; res.setHeader('retry-after', '7'); json(res, { error: 'not echoed' })
  33. })
  34. await expect(provider(api.url, { maxRetries: 3 }).embedBatch([{ text: '批次' }], 'document')).rejects.toMatchObject({ code: 'http-error', httpStatus: 429, retryAfterMs: 7000 })
  35. expect(api.calls()).toBe(1)
  36. const now = Date.UTC(2026, 8, 12, 10, 0, 0)
  37. expect(parseRetryAfter('Sat, 12 Sep 2026 10:00:09 GMT', now)).toBe(9000)
  38. expect(parseRetryAfter('Sat, 12 Sep 2026 09:00:00 GMT', now)).toBe(0)
  39. expect(parseRetryAfter('invalid', now)).toBeUndefined()
  40. })
  41. it('获取模型列表保留维度元数据,不发送嵌入文本', async () => {
  42. const api = await fixture((body, req, res) => {
  43. expect(req.method).toBe('GET'); expect(req.url).toBe('/v1/models'); expect(body).toBeUndefined()
  44. expect(req.headers.authorization).toBe('Bearer fixture-key')
  45. json(res, { data: [{ id: 'custom-embedding', supported_dimensions: [3, 2, 3], default_dimension: 3 }, { id: 'invalid-metadata', dimensions: -1 }] })
  46. })
  47. expect(await provider(api.url + '/embeddings').listModels()).toEqual({ models: [{ id: 'custom-embedding', dimensions: [3, 2], defaultDimension: 3 }, { id: 'invalid-metadata' }], limited: false })
  48. })
  49. it('Gemini 模型列表分页并过滤聊天模型', async () => {
  50. const api = await fixture((_body, req, res, call) => {
  51. expect(req.headers['x-goog-api-key']).toBe('fixture-key')
  52. const url = new URL(req.url!, api.url)
  53. expect(url.searchParams.get('pageToken')).toBe(call === 1 ? null : 'page-two')
  54. json(res, call === 1 ? { models: [{ name: 'models/chat', supportedGenerationMethods: ['generateContent'] }], nextPageToken: 'page-two' } : { models: [{ name: 'models/embed', supportedGenerationMethods: ['embedContent'] }] })
  55. })
  56. expect((await provider(api.url, { protocol: 'gemini-native' }).listModels()).models.map(model => model.id)).toEqual(['embed'])
  57. expect(api.calls()).toBe(2)
  58. })
  59. it('拒绝重复分页游标,避免无限请求', async () => {
  60. const api = await fixture((_body, _req, res) => json(res, { models: [], nextPageToken: 'same' }))
  61. await expect(provider(api.url, { protocol: 'gemini-native' }).listModels()).rejects.toThrow('分页游标')
  62. expect(api.calls()).toBe(2)
  63. })
  64. it.each(['openai-compatible', 'gemini-native'] as const)('检测 %s 默认维度省略维度参数,只发送固定文本', async protocol => {
  65. const api = await fixture((body, _req, res) => {
  66. if (protocol === 'openai-compatible') {
  67. expect(body.dimensions).toBeUndefined(); expect(body.input).toEqual(['Embedding dimension check.'])
  68. json(res, { data: [{ index: 0, embedding: [1, 2, 3, 4] }] })
  69. } else {
  70. expect(body.requests).toHaveLength(1); expect(body.requests[0].embedContentConfig.outputDimensionality).toBeUndefined()
  71. json(res, { embeddings: [{ values: [1, 2, 3, 4] }] })
  72. }
  73. })
  74. expect(await provider(api.url, { protocol }).probeDimensions()).toBe(4)
  75. expect(api.calls()).toBe(1)
  76. })
  77. it('OpenAI 格式发送维度、恢复乱序索引、按调用读取轮换凭据', async () => {
  78. let key = 'first-key'
  79. const seen: any[] = []
  80. const api = await fixture((body, req, res) => {
  81. seen.push({ body, url: req.url, key: req.headers.authorization })
  82. json(res, { data: body.input.map((_text: string, index: number) => ({ index, embedding: [index + 1, 0, 1] })).reverse() })
  83. })
  84. const client = provider(api.url, {}, async () => key)
  85. expect(await client.embed(texts, 'document')).toEqual([[1, 0, 1], [2, 0, 1]])
  86. expect(seen[0]).toMatchObject({ url: '/v1/embeddings', key: 'Bearer first-key', body: { dimensions: 3, encoding_format: 'float', input: ['第1章\nalpha', '第2章\nbeta'] } })
  87. key = 'second-key'
  88. await client.embed([{ text: '查询' }], 'query')
  89. expect(seen[1].key).toBe('Bearer second-key')
  90. expect(seen[1].body.input).toEqual(['查询'])
  91. })
  92. it('Gemini 原生逐文本请求,设置新配置字段、角色与输出维度', async () => {
  93. const seen: any[] = []
  94. const api = await fixture((body, req, res) => {
  95. seen.push({ body, url: req.url, key: req.headers['x-goog-api-key'] })
  96. json(res, { embeddings: body.requests.map(() => ({ values: [1, 2, 3] })) })
  97. })
  98. const client = provider(api.url, { protocol: 'gemini-native', model: 'models/gemini-test' })
  99. expect(await client.embed(texts, 'document')).toHaveLength(2)
  100. expect(seen[0]).toMatchObject({ url: '/v1/models/gemini-test:batchEmbedContents', key: 'fixture-key' })
  101. expect(seen[0].body.requests[0]).toEqual({
  102. model: 'models/gemini-test', content: { parts: [{ text: 'alpha' }] },
  103. embedContentConfig: { autoTruncate: false, outputDimensionality: 3, taskType: 'RETRIEVAL_DOCUMENT', title: '第1章' },
  104. })
  105. await client.embed([{ text: '查找' }], 'query')
  106. expect(seen[1].body.requests[0].embedContentConfig).toEqual({ autoTruncate: false, outputDimensionality: 3, taskType: 'RETRIEVAL_QUERY' })
  107. })
  108. it('固定维度 API 可不传尺寸参数,仍严格校验;角色前缀影响缓存版本', async () => {
  109. const api = await fixture((body, _req, res) => {
  110. expect(body.dimensions).toBeUndefined()
  111. expect(body.input).toEqual(['查找指令\n查询'])
  112. json(res, { data: [{ index: 0, embedding: [1, 0, 0] }] })
  113. })
  114. const first = provider(api.url, { sendDimensions: false, queryPrefix: '查找指令' })
  115. await first.embed([{ text: '查询' }], 'query')
  116. expect(first.metadata.dimensions).toBe(3)
  117. const changed = provider(api.url, { sendDimensions: false, dimensions: 4, queryPrefix: '查找指令' })
  118. expect(changed.metadata.revision).not.toBe(first.metadata.revision)
  119. expect(provider(api.url, { sendDimensions: false, queryPrefix: '另一指令' }).metadata.revision).not.toBe(first.metadata.revision)
  120. })
  121. it.each([
  122. { data: [] },
  123. { data: [{ index: 1, embedding: [1, 2, 3] }] },
  124. { data: [{ index: 0, embedding: [1, 2] }] },
  125. { data: [{ index: 0, embedding: [0, 0, 0] }] },
  126. { data: [{ index: 0, embedding: [null, 0, 0] }] },
  127. ])('拒绝无效响应而不返回错配向量:%j', async value => {
  128. const api = await fixture((_body, _req, res) => json(res, value))
  129. await expect(provider(api.url).embed([{ text: '正文' }], 'document')).rejects.toMatchObject({ code: 'invalid-response' })
  130. expect(api.calls()).toBe(1)
  131. })
  132. it('重复批次索引拒绝,空输入不被过滤后发出', async () => {
  133. const api = await fixture((_body, _req, res) => json(res, { data: [{ index: 0, embedding: [1, 0, 0] }, { index: 0, embedding: [0, 1, 0] }] }))
  134. const client = provider(api.url)
  135. await expect(client.embed(texts, 'document')).rejects.toMatchObject({ code: 'invalid-response' })
  136. await expect(client.embed([{ text: '' }, { text: '正文' }], 'document')).rejects.toMatchObject({ code: 'invalid-input' })
  137. expect(api.calls()).toBe(1)
  138. })
  139. it('批量分组保留顺序,临时故障有界重试,鉴权错误不回显正文', async () => {
  140. const api = await fixture((body, _req, res, call) => {
  141. if (call === 1) { res.statusCode = 429; res.end('private-response'); return }
  142. json(res, { data: body.input.map((value: string, index: number) => ({ index, embedding: [Number(value), 1, 0] })) })
  143. })
  144. expect(await provider(api.url, { batchSize: 2, maxRetries: 1 }).embed([1, 2, 3].map(value => ({ text: String(value) })), 'query')).toEqual([[1, 1, 0], [2, 1, 0], [3, 1, 0]])
  145. expect(api.calls()).toBe(3)
  146. const denied = await fixture((_body, _req, res) => { res.statusCode = 401; res.end('fixture-key private manuscript') })
  147. await expect(provider(denied.url, { maxRetries: 3 }).embed(texts, 'document')).rejects.toThrow('HTTP 401')
  148. expect(denied.calls()).toBe(1)
  149. })
  150. it('预取消不请求;等待凭据和网络时取消/卸载均能结束', async () => {
  151. let requested!: () => void
  152. const observed = new Promise<void>(resolve => { requested = resolve })
  153. const api = await fixture(() => requested())
  154. const client = provider(api.url)
  155. const before = new AbortController(); before.abort(new Error('预取消'))
  156. await expect(client.embed(texts, 'document', before.signal)).rejects.toThrow('预取消')
  157. expect(api.calls()).toBe(0)
  158. const pending = client.embed(texts, 'document')
  159. await observed
  160. const rejection = expect(pending).rejects.toMatchObject({ code: 'provider-changed' })
  161. await client.close(); await rejection
  162. const waitingKey = provider(api.url, {}, () => new Promise(() => {}))
  163. const keyOperation = waitingKey.embed(texts, 'document')
  164. const keyRejection = expect(keyOperation).rejects.toMatchObject({ code: 'provider-changed' })
  165. await waitingKey.close(); await keyRejection
  166. })
  167. it('超时重试次数有界,重定向不转发凭据', async () => {
  168. const slow = await fixture(() => {})
  169. await expect(provider(slow.url, { timeoutMs: 100, maxRetries: 1 }).embed(texts, 'document')).rejects.toMatchObject({ code: 'timeout' })
  170. expect(slow.calls()).toBe(2)
  171. const target = await fixture((_body, _req, res) => json(res, {}))
  172. const redirect = await fixture((_body, _req, res) => { res.statusCode = 307; res.setHeader('location', target.url + '/embeddings'); res.end() })
  173. await expect(provider(redirect.url).embed(texts, 'document')).rejects.toMatchObject({ code: 'transport' })
  174. expect(target.calls()).toBe(0)
  175. })
  176. })
  177. describe('设置约束', () => {
  178. it('未启用可保留空配置;启用必须给维度,且端点不得携带密钥', () => {
  179. expect(resolveSettings({})).toBeUndefined()
  180. for (const dimensions of [undefined, 0, -1, 1.5, 65_537]) expect(() => resolveSettings({ enabled: true, baseURL: 'https://example.invalid/v1', model: 'test', dimensions })).toThrow(/维度/)
  181. expect(() => resolveSettings({ enabled: true, baseURL: 'https://name:secret@example.invalid', model: 'test', dimensions: 3 })).toThrow(/地址/)
  182. })
  183. })