model-request.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. AgentRuntime,
  4. AgentToolRegistry,
  5. AnthropicMessagesModelClient,
  6. ModelProviderError,
  7. ModelProviderRegistry,
  8. authorizedModelProviderPreset,
  9. createModelClient,
  10. toAnthropicMessagesPayload,
  11. toGeminiContentsPayload,
  12. normalizeOpenAIChatResponse,
  13. resolveModelProviderConfigs,
  14. toOpenAIResponsesPayload,
  15. toOpenAIChatPayload,
  16. toPromptCompletionPayload,
  17. } from '../../packages/ekko-agent/src/index'
  18. import type { ModelProviderConfig } from '../../packages/ekko-agent/src/index'
  19. const providerConfig: ModelProviderConfig = {
  20. id: 'deepseek',
  21. type: 'openai-compatible',
  22. apiKey: 'test-key',
  23. baseUrl: 'https://api.deepseek.com/v1',
  24. defaultModel: 'deepseek-chat',
  25. }
  26. describe('ekko-agent model requests', () => {
  27. it('completes a Codex Responses tool loop when terminal output arrays are empty', async () => {
  28. const encoder = new TextEncoder()
  29. let call = 0
  30. const requestBodies: Array<Record<string, any>> = []
  31. const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
  32. requestBodies.push(JSON.parse(String(init?.body)))
  33. call += 1
  34. const frames = call === 1
  35. ? [
  36. 'event: response.output_item.done\ndata: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_weather","name":"weather","arguments":"{\\"city\\":\\"Xiamen\\"}"}}\n\n',
  37. 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_tool","status":"completed","output":[]}}\n\n',
  38. ]
  39. : [
  40. 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Sunny"}\n\n',
  41. 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_final","status":"completed","output":[]}}\n\n',
  42. ]
  43. return new Response(new ReadableStream({
  44. start(controller) {
  45. for (const frame of frames) controller.enqueue(encoder.encode(frame))
  46. controller.close()
  47. },
  48. }), { status: 200 })
  49. })
  50. const tools = new AgentToolRegistry()
  51. tools.register({
  52. definition: {
  53. name: 'weather',
  54. parameters: {
  55. type: 'object',
  56. properties: { city: { type: 'string' } },
  57. required: ['city'],
  58. },
  59. },
  60. async execute(input) {
  61. return { ok: true, content: `${input.city}: 30C` }
  62. },
  63. })
  64. const client = createModelClient({
  65. id: 'openai-codex',
  66. type: 'openai-compatible',
  67. requestStyle: 'openai-responses',
  68. apiKey: 'token',
  69. baseUrl: 'https://chatgpt.com/backend-api/codex',
  70. defaultModel: 'gpt-5.6-terra',
  71. }, { fetch: fetchMock })
  72. const runtime = new AgentRuntime({ modelClient: client, tools })
  73. const result = await runtime.run({ messages: ['Check Xiamen weather'] })
  74. expect(result.output.content).toBe('Sunny')
  75. expect(fetchMock).toHaveBeenCalledTimes(2)
  76. expect(requestBodies[1]?.input).toEqual(expect.arrayContaining([
  77. expect.objectContaining({
  78. type: 'function_call',
  79. call_id: 'call_weather',
  80. name: 'weather',
  81. }),
  82. {
  83. type: 'function_call_output',
  84. call_id: 'call_weather',
  85. output: 'Xiamen: 30C',
  86. },
  87. ]))
  88. expect(requestBodies.every(body => body.stream === true)).toBe(true)
  89. })
  90. it('defines first-class request presets for authorized providers', () => {
  91. expect(authorizedModelProviderPreset('nous')).toMatchObject({
  92. id: 'nous',
  93. baseUrl: 'https://inference-api.nousresearch.com/v1',
  94. requestStyle: 'openai-chat',
  95. })
  96. expect(authorizedModelProviderPreset('openai-codex')).toMatchObject({
  97. id: 'openai-codex',
  98. baseUrl: 'https://chatgpt.com/backend-api/codex',
  99. requestStyle: 'openai-responses',
  100. })
  101. expect(authorizedModelProviderPreset('xai-oauth')).toMatchObject({
  102. id: 'xai-oauth',
  103. baseUrl: 'https://api.x.ai/v1',
  104. requestStyle: 'openai-responses',
  105. })
  106. expect(authorizedModelProviderPreset('qwen-oauth')).toMatchObject({
  107. id: 'qwen-oauth',
  108. baseUrl: 'https://portal.qwen.ai/v1',
  109. requestStyle: 'openai-chat',
  110. })
  111. })
  112. it.each([
  113. {
  114. provider: 'nous',
  115. url: 'https://inference-api.nousresearch.com/v1/chat/completions',
  116. response: { choices: [{ message: { content: 'Nous' }, finish_reason: 'stop' }] },
  117. expectedContent: 'Nous',
  118. },
  119. {
  120. provider: 'xai-oauth',
  121. url: 'https://api.x.ai/v1/responses',
  122. response: { output_text: 'xAI', status: 'completed' },
  123. expectedContent: 'xAI',
  124. },
  125. ])('sends $provider access tokens to its default endpoint', async ({
  126. provider,
  127. url,
  128. response,
  129. expectedContent,
  130. }) => {
  131. const fetchMock = vi.fn(async () => new Response(JSON.stringify(response)))
  132. const resolved = resolveModelProviderConfigs({
  133. provider,
  134. apiKey: 'oauth-access-token',
  135. model: 'test-model',
  136. })
  137. const client = createModelClient(resolved.providerConfig, { fetch: fetchMock })
  138. const result = await client.create({ messages: [{ role: 'user', content: 'Hello' }] })
  139. expect(result.content).toBe(expectedContent)
  140. expect(fetchMock.mock.calls[0]?.[0]).toBe(url)
  141. expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
  142. authorization: 'Bearer oauth-access-token',
  143. })
  144. })
  145. it('sends Codex OAuth identity headers to the ChatGPT Responses endpoint', async () => {
  146. const tokenPayload = Buffer.from(JSON.stringify({
  147. 'https://api.openai.com/auth': { chatgpt_account_id: 'account-123' },
  148. })).toString('base64url')
  149. const accessToken = `header.${tokenPayload}.signature`
  150. const encoder = new TextEncoder()
  151. const fetchMock = vi.fn(async () => new Response(new ReadableStream({
  152. start(controller) {
  153. controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Codex"}\n\n'))
  154. controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_codex","status":"completed","output":[]}}\n\n'))
  155. controller.close()
  156. },
  157. }), { status: 200 }))
  158. const resolved = resolveModelProviderConfigs({
  159. provider: 'openai-codex',
  160. apiKey: accessToken,
  161. model: 'gpt-5-codex',
  162. })
  163. const result = await createModelClient(resolved.providerConfig, { fetch: fetchMock }).create({
  164. messages: [{ role: 'user', content: 'Hello' }],
  165. metadata: { session_id: 'session-1', profile: 'default' },
  166. maxTokens: 1024,
  167. temperature: 0.2,
  168. })
  169. expect(result.content).toBe('Codex')
  170. expect(fetchMock.mock.calls[0]?.[0]).toBe('https://chatgpt.com/backend-api/codex/responses')
  171. expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
  172. authorization: `Bearer ${accessToken}`,
  173. 'user-agent': 'codex_cli_rs/0.0.0 (Ekko Agent)',
  174. originator: 'codex_cli_rs',
  175. 'ChatGPT-Account-ID': 'account-123',
  176. })
  177. const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
  178. expect(requestBody).toMatchObject({ store: false, stream: true })
  179. expect(requestBody).not.toHaveProperty('metadata')
  180. expect(requestBody).not.toHaveProperty('max_output_tokens')
  181. expect(requestBody).not.toHaveProperty('temperature')
  182. })
  183. it('keeps Codex streamed text when the terminal response output is empty', async () => {
  184. const encoder = new TextEncoder()
  185. const fetchMock = vi.fn(async () => new Response(new ReadableStream({
  186. start(controller) {
  187. controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"OK"}\n\n'))
  188. controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-terra","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}}\n\n'))
  189. controller.close()
  190. },
  191. }), { status: 200 }))
  192. const client = createModelClient({
  193. id: 'openai-codex',
  194. type: 'openai-compatible',
  195. requestStyle: 'openai-responses',
  196. apiKey: 'token',
  197. baseUrl: 'https://chatgpt.com/backend-api/codex',
  198. defaultModel: 'gpt-5.6-terra',
  199. }, { fetch: fetchMock })
  200. const events = []
  201. for await (const event of client.stream({
  202. messages: [{ role: 'user', content: 'Hello' }],
  203. })) events.push(event)
  204. expect(events).toContainEqual({ type: 'text-delta', text: 'OK' })
  205. expect(events).toContainEqual(expect.objectContaining({
  206. type: 'done',
  207. response: expect.objectContaining({ content: 'OK', finishReason: 'completed' }),
  208. }))
  209. })
  210. it('requests and streams Responses reasoning summaries', async () => {
  211. const encoder = new TextEncoder()
  212. const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
  213. expect(JSON.parse(String(init?.body))).toMatchObject({
  214. reasoning: {
  215. effort: 'high',
  216. summary: 'auto',
  217. },
  218. })
  219. return new Response(new ReadableStream({
  220. start(controller) {
  221. controller.enqueue(encoder.encode('event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","delta":"Checked the constraints."}\n\n'))
  222. controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Done"}\n\n'))
  223. controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_reasoning","status":"completed","output":[]}}\n\n'))
  224. controller.close()
  225. },
  226. }), { status: 200 })
  227. })
  228. const client = createModelClient({
  229. id: 'custom:responses',
  230. type: 'openai-compatible',
  231. requestStyle: 'openai-responses',
  232. apiKey: 'token',
  233. defaultModel: 'reasoning-model',
  234. }, { fetch: fetchMock })
  235. const events = []
  236. for await (const event of client.stream({
  237. messages: [{ role: 'user', content: 'Solve it' }],
  238. reasoningEffort: 'high',
  239. reasoningSummary: 'auto',
  240. })) events.push(event)
  241. expect(events).toContainEqual({ type: 'reasoning-delta', text: 'Checked the constraints.' })
  242. expect(events).toContainEqual(expect.objectContaining({
  243. type: 'done',
  244. response: expect.objectContaining({
  245. content: 'Done',
  246. reasoning: 'Checked the constraints.',
  247. }),
  248. }))
  249. })
  250. it('routes Responses commentary-phase messages through the reasoning channel', async () => {
  251. const encoder = new TextEncoder()
  252. const fetchMock = vi.fn(async () => new Response(new ReadableStream({
  253. start(controller) {
  254. controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_commentary","type":"message","phase":"commentary","content":[]}}\n\n'))
  255. controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_commentary","delta":"**Loading model skill**"}\n\n'))
  256. controller.enqueue(encoder.encode('event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_commentary","type":"message","phase":"commentary","content":[{"type":"output_text","text":"**Loading model skill**"}]}}\n\n'))
  257. controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":1,"item":{"id":"msg_final","type":"message","phase":"final_answer","content":[]}}\n\n'))
  258. controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":1,"item_id":"msg_final","delta":"Ready."}\n\n'))
  259. controller.enqueue(encoder.encode('event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_final","type":"message","phase":"final_answer","content":[{"type":"output_text","text":"Ready."}]}}\n\n'))
  260. controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_commentary","status":"completed","output":[]}}\n\n'))
  261. controller.close()
  262. },
  263. }), { status: 200 }))
  264. const client = createModelClient({
  265. id: 'custom:responses',
  266. type: 'openai-compatible',
  267. requestStyle: 'openai-responses',
  268. apiKey: 'token',
  269. defaultModel: 'reasoning-model',
  270. }, { fetch: fetchMock })
  271. const events = []
  272. for await (const event of client.stream({
  273. messages: [{ role: 'user', content: 'Use the model skill.' }],
  274. })) events.push(event)
  275. expect(events).toContainEqual({ type: 'reasoning-delta', text: '**Loading model skill**' })
  276. expect(events).not.toContainEqual({ type: 'text-delta', text: '**Loading model skill**' })
  277. expect(events).toContainEqual({ type: 'text-delta', text: 'Ready.' })
  278. expect(events).toContainEqual(expect.objectContaining({
  279. type: 'done',
  280. response: expect.objectContaining({
  281. content: 'Ready.',
  282. reasoning: '**Loading model skill**',
  283. }),
  284. }))
  285. })
  286. it('keeps non-Codex Responses create requests non-streaming', async () => {
  287. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  288. output_text: 'xAI',
  289. status: 'completed',
  290. })))
  291. const client = createModelClient({
  292. id: 'xai-oauth',
  293. type: 'openai-compatible',
  294. requestStyle: 'openai-responses',
  295. apiKey: 'token',
  296. baseUrl: 'https://api.x.ai/v1',
  297. defaultModel: 'grok-4.5',
  298. }, { fetch: fetchMock })
  299. const response = await client.create({ messages: [{ role: 'user', content: 'Hello' }] })
  300. const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
  301. expect(response.content).toBe('xAI')
  302. expect(requestBody.stream).toBe(false)
  303. })
  304. it('emits Responses function calls from output item events', async () => {
  305. const encoder = new TextEncoder()
  306. const fetchMock = vi.fn(async () => new Response(new ReadableStream({
  307. start(controller) {
  308. controller.enqueue(encoder.encode('event: response.output_item.done\ndata: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"read_file","arguments":"{\\"path\\":\\"README.md\\"}"}}\n\n'))
  309. controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_2","status":"completed","output":[]}}\n\n'))
  310. controller.close()
  311. },
  312. }), { status: 200 }))
  313. const client = createModelClient({
  314. id: 'openai-codex',
  315. type: 'openai-compatible',
  316. requestStyle: 'openai-responses',
  317. apiKey: 'token',
  318. defaultModel: 'gpt-5.6-terra',
  319. }, { fetch: fetchMock })
  320. const events = []
  321. for await (const event of client.stream({ messages: [{ role: 'user', content: 'Read it' }] })) {
  322. events.push(event)
  323. }
  324. expect(events).toContainEqual({
  325. type: 'tool-call',
  326. toolCall: {
  327. id: 'call_1',
  328. name: 'read_file',
  329. arguments: { path: 'README.md' },
  330. rawArguments: '{"path":"README.md"}',
  331. },
  332. })
  333. expect(events).toContainEqual(expect.objectContaining({
  334. type: 'done',
  335. response: expect.objectContaining({
  336. toolCalls: [expect.objectContaining({ id: 'call_1', name: 'read_file' })],
  337. }),
  338. }))
  339. })
  340. it('omits unsupported metadata from xAI OAuth Responses requests', async () => {
  341. const payload = toOpenAIResponsesPayload({
  342. id: 'xai-oauth',
  343. type: 'openai-compatible',
  344. requestStyle: 'openai-responses',
  345. defaultModel: 'grok-4.5',
  346. }, {
  347. messages: [{ role: 'user', content: 'Hello' }],
  348. metadata: { session_id: 'session-1', profile: 'default' },
  349. context: { responseId: 'response-from-first-turn' },
  350. })
  351. expect(payload).not.toHaveProperty('metadata')
  352. expect(payload.previous_response_id).toBeUndefined()
  353. })
  354. it('sends Qwen OAuth identity headers to the Portal Chat Completions endpoint', async () => {
  355. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  356. choices: [{ message: { content: 'Qwen' }, finish_reason: 'stop' }],
  357. })))
  358. const resolved = resolveModelProviderConfigs({
  359. provider: 'qwen-oauth',
  360. apiKey: 'qwen-access-token',
  361. model: 'qwen3-coder-plus',
  362. })
  363. const result = await createModelClient(resolved.providerConfig, { fetch: fetchMock }).create({
  364. messages: [{ role: 'user', content: 'Hello' }],
  365. })
  366. expect(result.content).toBe('Qwen')
  367. expect(fetchMock.mock.calls[0]?.[0]).toBe('https://portal.qwen.ai/v1/chat/completions')
  368. expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
  369. authorization: 'Bearer qwen-access-token',
  370. 'X-DashScope-CacheControl': 'enable',
  371. 'X-DashScope-AuthType': 'qwen-oauth',
  372. })
  373. expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
  374. messages: [{
  375. role: 'user',
  376. content: [{ type: 'text', text: 'Hello' }],
  377. }],
  378. vl_high_resolution_images: true,
  379. })
  380. })
  381. it('resolves provider configs from explicit api mode with inferred fallback', () => {
  382. const resolved = resolveModelProviderConfigs({
  383. provider: 'glm',
  384. baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
  385. apiKey: 'secret',
  386. model: 'glm-5.2',
  387. apiMode: 'codex_responses',
  388. })
  389. expect(resolved.providerConfig).toMatchObject({
  390. id: 'glm',
  391. type: 'openai-compatible',
  392. requestStyle: 'openai-responses',
  393. baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
  394. apiKey: 'secret',
  395. defaultModel: 'glm-5.2',
  396. })
  397. expect(resolved.fallbackProviderConfig).toMatchObject({
  398. requestStyle: 'openai-chat',
  399. defaultModel: 'glm-5.2',
  400. })
  401. })
  402. it('infers anthropic provider configs from anthropic URLs', () => {
  403. const resolved = resolveModelProviderConfigs({
  404. provider: 'custom',
  405. baseUrl: 'https://api.z.ai/api/anthropic',
  406. model: 'glm-5.2',
  407. })
  408. expect(resolved.providerConfig).toMatchObject({
  409. type: 'anthropic',
  410. requestStyle: 'anthropic-messages',
  411. })
  412. expect(resolved.fallbackProviderConfig).toBeUndefined()
  413. })
  414. it('converts internal requests to OpenAI-compatible chat payloads', () => {
  415. const payload = toOpenAIChatPayload(providerConfig, {
  416. messages: [
  417. { role: 'system', content: 'Be concise.' },
  418. { role: 'user', content: 'List files.' },
  419. ],
  420. tools: [
  421. {
  422. name: 'read_file',
  423. description: 'Read a file',
  424. parameters: {
  425. type: 'object',
  426. properties: {
  427. path: { type: 'string' },
  428. },
  429. },
  430. },
  431. ],
  432. temperature: 0.2,
  433. maxTokens: 1024,
  434. })
  435. expect(payload).toMatchObject({
  436. model: 'deepseek-chat',
  437. messages: [
  438. { role: 'system', content: 'Be concise.' },
  439. { role: 'user', content: 'List files.' },
  440. ],
  441. temperature: 0.2,
  442. max_tokens: 1024,
  443. tools: [
  444. {
  445. type: 'function',
  446. function: {
  447. name: 'read_file',
  448. description: 'Read a file',
  449. },
  450. },
  451. ],
  452. })
  453. })
  454. it('normalizes OpenAI-compatible responses into the internal shape', () => {
  455. const response = normalizeOpenAIChatResponse('deepseek', {
  456. id: 'chatcmpl_1',
  457. model: 'deepseek-chat',
  458. choices: [
  459. {
  460. message: {
  461. content: 'Done.',
  462. tool_calls: [
  463. {
  464. id: 'call_1',
  465. type: 'function',
  466. function: {
  467. name: 'read_file',
  468. arguments: '{"path":"README.md"}',
  469. },
  470. },
  471. ],
  472. },
  473. finish_reason: 'tool_calls',
  474. },
  475. ],
  476. usage: {
  477. prompt_tokens: 10,
  478. completion_tokens: 5,
  479. total_tokens: 15,
  480. prompt_tokens_details: { cached_tokens: 7 },
  481. completion_tokens_details: { reasoning_tokens: 2 },
  482. },
  483. })
  484. expect(response).toMatchObject({
  485. id: 'chatcmpl_1',
  486. model: 'deepseek-chat',
  487. content: 'Done.',
  488. finishReason: 'tool_calls',
  489. usage: {
  490. inputTokens: 3,
  491. outputTokens: 5,
  492. totalTokens: 15,
  493. cacheReadTokens: 7,
  494. reasoningTokens: 2,
  495. },
  496. toolCalls: [
  497. {
  498. id: 'call_1',
  499. name: 'read_file',
  500. arguments: { path: 'README.md' },
  501. },
  502. ],
  503. })
  504. })
  505. it('creates OpenAI-compatible clients through the registry', () => {
  506. const registry = new ModelProviderRegistry()
  507. registry.register(providerConfig)
  508. const client = registry.create('deepseek', {
  509. fetch: vi.fn(),
  510. })
  511. expect(client.provider).toBe('deepseek')
  512. expect(client.requestStyle).toBe('openai-chat')
  513. expect(client.capabilities.tools).toBe(true)
  514. expect(registry.list()).toHaveLength(1)
  515. })
  516. it('creates clients for every supported request style', () => {
  517. expect(createModelClient({
  518. id: 'openai-responses',
  519. type: 'openai',
  520. requestStyle: 'openai-responses',
  521. defaultModel: 'gpt-4.1',
  522. }).requestStyle).toBe('openai-responses')
  523. expect(createModelClient({
  524. id: 'claude',
  525. type: 'anthropic',
  526. defaultModel: 'claude-sonnet',
  527. }).requestStyle).toBe('anthropic-messages')
  528. expect(createModelClient({
  529. id: 'gemini',
  530. type: 'gemini',
  531. defaultModel: 'gemini-2.5-pro',
  532. }).requestStyle).toBe('gemini-contents')
  533. expect(createModelClient({
  534. id: 'legacy',
  535. type: 'custom',
  536. requestStyle: 'prompt-completion',
  537. defaultModel: 'legacy-text',
  538. }).requestStyle).toBe('prompt-completion')
  539. expect(createModelClient({
  540. id: 'runtime',
  541. type: 'custom',
  542. defaultModel: 'runtime-agent',
  543. }).requestStyle).toBe('custom-runtime')
  544. })
  545. it('converts internal requests to self-contained OpenAI Responses payloads', () => {
  546. const payload = toOpenAIResponsesPayload({
  547. id: 'openai',
  548. type: 'openai',
  549. requestStyle: 'openai-responses',
  550. defaultModel: 'gpt-4.1',
  551. }, {
  552. messages: [
  553. { role: 'system', content: 'Be direct.' },
  554. { role: 'user', content: 'Search docs.' },
  555. ],
  556. tools: [{ name: 'search', parameters: { type: 'object' } }],
  557. maxTokens: 500,
  558. reasoningEffort: 'medium',
  559. reasoningSummary: 'auto',
  560. context: { responseId: 'resp_previous' },
  561. })
  562. expect(payload).toMatchObject({
  563. model: 'gpt-4.1',
  564. instructions: 'Be direct.',
  565. input: [{ role: 'user', content: 'Search docs.' }],
  566. max_output_tokens: 500,
  567. reasoning: { effort: 'medium', summary: 'auto' },
  568. tools: [{ type: 'function', name: 'search' }],
  569. store: false,
  570. })
  571. expect(payload).not.toHaveProperty('previous_response_id')
  572. })
  573. it('replays Responses tool calls and results with native input item types', () => {
  574. const payload = toOpenAIResponsesPayload({
  575. id: 'openai-codex',
  576. type: 'openai-compatible',
  577. requestStyle: 'openai-responses',
  578. defaultModel: 'gpt-5.6-terra',
  579. }, {
  580. messages: [
  581. { role: 'user', content: 'Check the weather.' },
  582. {
  583. role: 'assistant',
  584. content: '',
  585. toolCalls: [{
  586. id: 'call_weather',
  587. name: 'web_search',
  588. arguments: { query: 'Xiamen weather today' },
  589. rawArguments: '{"query":"Xiamen weather today"}',
  590. }],
  591. },
  592. {
  593. role: 'tool',
  594. toolCallId: 'call_weather',
  595. name: 'web_search',
  596. content: 'Sunny, 30°C',
  597. },
  598. ],
  599. })
  600. expect(payload.input).toEqual([
  601. { role: 'user', content: 'Check the weather.' },
  602. {
  603. type: 'function_call',
  604. call_id: 'call_weather',
  605. name: 'web_search',
  606. arguments: '{"query":"Xiamen weather today"}',
  607. },
  608. {
  609. type: 'function_call_output',
  610. call_id: 'call_weather',
  611. output: 'Sunny, 30°C',
  612. },
  613. ])
  614. })
  615. it('omits invalid empty-name tool history from Responses replay', () => {
  616. const payload = toOpenAIResponsesPayload({
  617. id: 'custom:fun-codex',
  618. type: 'openai-compatible',
  619. requestStyle: 'openai-responses',
  620. defaultModel: 'gpt-5.5',
  621. }, {
  622. messages: [
  623. { role: 'user', content: 'Check the weather.' },
  624. {
  625. role: 'assistant',
  626. content: '',
  627. toolCalls: [{
  628. id: 'call_invalid',
  629. name: '',
  630. arguments: { url: 'https://example.com' },
  631. }],
  632. },
  633. {
  634. role: 'tool',
  635. toolCallId: 'call_invalid',
  636. name: '',
  637. content: 'Unknown tool: ',
  638. },
  639. { role: 'user', content: 'Try again.' },
  640. ],
  641. })
  642. expect(payload.input).toEqual([
  643. { role: 'user', content: 'Check the weather.' },
  644. { role: 'user', content: 'Try again.' },
  645. ])
  646. })
  647. it('converts internal requests to Anthropic Messages payloads', () => {
  648. const payload = toAnthropicMessagesPayload({
  649. id: 'claude',
  650. type: 'anthropic',
  651. defaultModel: 'claude-sonnet',
  652. }, {
  653. messages: [
  654. { role: 'system', content: 'Use short answers.' },
  655. { role: 'user', content: 'Hello.' },
  656. ],
  657. tools: [{ name: 'read_file', parameters: { type: 'object' } }],
  658. })
  659. expect(payload).toMatchObject({
  660. model: 'claude-sonnet',
  661. system: 'Use short answers.',
  662. messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello.' }] }],
  663. max_tokens: 4096,
  664. tools: [{ name: 'read_file', input_schema: { type: 'object' } }],
  665. })
  666. })
  667. it('calls Anthropic-compatible /anthropic bases through /v1/messages', async () => {
  668. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  669. content: [{ type: 'text', text: 'OK' }],
  670. stop_reason: 'end_turn',
  671. }), { status: 200 }))
  672. const client = new AnthropicMessagesModelClient({
  673. id: 'custom:glm-anthropic',
  674. type: 'anthropic',
  675. requestStyle: 'anthropic-messages',
  676. baseUrl: 'https://api.z.ai/api/anthropic',
  677. apiKey: 'test-key',
  678. defaultModel: 'glm-5.2',
  679. }, { fetch: fetchMock })
  680. const response = await client.create({
  681. messages: [{ role: 'user', content: 'hi' }],
  682. })
  683. expect(response.content).toBe('OK')
  684. expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.z.ai/api/anthropic/v1/messages')
  685. expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
  686. authorization: 'Bearer test-key',
  687. 'x-api-key': 'test-key',
  688. })
  689. })
  690. it('merges Anthropic streaming input, output, and cache usage', async () => {
  691. const encoder = new TextEncoder()
  692. const fetchMock = vi.fn(async () => new Response(new ReadableStream({
  693. start(controller) {
  694. controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":0,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}}\n\n'))
  695. controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}\n\n'))
  696. controller.enqueue(encoder.encode('event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}\n\n'))
  697. controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'))
  698. controller.close()
  699. },
  700. }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }))
  701. const client = new AnthropicMessagesModelClient({
  702. id: 'anthropic',
  703. type: 'anthropic',
  704. requestStyle: 'anthropic-messages',
  705. baseUrl: 'https://api.anthropic.com',
  706. apiKey: 'test-key',
  707. defaultModel: 'claude-sonnet',
  708. }, { fetch: fetchMock })
  709. const events = []
  710. for await (const event of client.stream({
  711. messages: [{ role: 'user', content: 'hi' }],
  712. stream: true,
  713. })) events.push(event)
  714. expect(events).toContainEqual({
  715. type: 'usage',
  716. usage: {
  717. inputTokens: 100,
  718. outputTokens: 7,
  719. totalTokens: 107,
  720. cacheReadTokens: 80,
  721. cacheWriteTokens: 5,
  722. reasoningTokens: undefined,
  723. },
  724. })
  725. })
  726. it('throws Anthropic-compatible JSON error bodies even when HTTP status is 200', async () => {
  727. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  728. code: 500,
  729. msg: '404 NOT_FOUND',
  730. success: false,
  731. }), { status: 200 }))
  732. const client = new AnthropicMessagesModelClient({
  733. id: 'custom:glm-anthropic',
  734. type: 'anthropic',
  735. requestStyle: 'anthropic-messages',
  736. baseUrl: 'https://api.z.ai/api/anthropic/messages',
  737. apiKey: 'test-key',
  738. defaultModel: 'glm-5.2',
  739. }, { fetch: fetchMock })
  740. await expect(client.create({
  741. messages: [{ role: 'user', content: 'hi' }],
  742. })).rejects.toMatchObject({
  743. message: '404 NOT_FOUND',
  744. provider: 'custom:glm-anthropic',
  745. })
  746. })
  747. it('converts internal requests to Gemini Contents payloads', () => {
  748. const payload = toGeminiContentsPayload({
  749. id: 'gemini',
  750. type: 'gemini',
  751. defaultModel: 'gemini-2.5-pro',
  752. }, {
  753. messages: [
  754. { role: 'system', content: 'Be brief.' },
  755. { role: 'user', content: 'Hello.' },
  756. ],
  757. tools: [{ name: 'lookup', parameters: { type: 'object' } }],
  758. temperature: 0.1,
  759. })
  760. expect(payload).toMatchObject({
  761. systemInstruction: { parts: [{ text: 'Be brief.' }] },
  762. contents: [{ role: 'user', parts: [{ text: 'Hello.' }] }],
  763. generationConfig: { temperature: 0.1 },
  764. tools: [{ functionDeclarations: [{ name: 'lookup' }] }],
  765. })
  766. })
  767. it('converts internal requests to prompt completion payloads', () => {
  768. const payload = toPromptCompletionPayload({
  769. id: 'legacy',
  770. type: 'custom',
  771. requestStyle: 'prompt-completion',
  772. defaultModel: 'legacy-text',
  773. }, {
  774. messages: [
  775. { role: 'system', content: 'Instruction.' },
  776. { role: 'user', content: 'Question.' },
  777. ],
  778. maxTokens: 100,
  779. })
  780. expect(payload).toEqual({
  781. model: 'legacy-text',
  782. prompt: 'SYSTEM: Instruction.\n\nUSER: Question.',
  783. max_tokens: 100,
  784. stream: undefined,
  785. temperature: undefined,
  786. })
  787. })
  788. it('sends requests with provider headers and normalizes the response', async () => {
  789. const fetchMock = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response(JSON.stringify({
  790. id: 'chatcmpl_2',
  791. model: 'deepseek-chat',
  792. choices: [{ message: { content: 'Hello.' }, finish_reason: 'stop' }],
  793. })))
  794. const client = createModelClient(providerConfig, { fetch: fetchMock })
  795. const response = await client.create({
  796. messages: [{ role: 'user', content: 'Hello' }],
  797. })
  798. expect(response.content).toBe('Hello.')
  799. expect(fetchMock).toHaveBeenCalledWith(
  800. 'https://api.deepseek.com/v1/chat/completions',
  801. expect.objectContaining({
  802. method: 'POST',
  803. headers: expect.objectContaining({
  804. authorization: 'Bearer test-key',
  805. 'content-type': 'application/json',
  806. }),
  807. body: expect.stringContaining('"model":"deepseek-chat"'),
  808. }),
  809. )
  810. })
  811. it('throws normalized provider errors for failing HTTP responses', async () => {
  812. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  813. error: {
  814. message: 'rate limited',
  815. },
  816. }), { status: 429 }))
  817. const client = createModelClient(providerConfig, { fetch: fetchMock })
  818. await expect(client.create({
  819. messages: [{ role: 'user', content: 'Hello' }],
  820. })).rejects.toMatchObject({
  821. name: 'ModelProviderError',
  822. provider: 'deepseek',
  823. statusCode: 429,
  824. retryable: true,
  825. message: 'rate limited',
  826. } satisfies Partial<ModelProviderError>)
  827. })
  828. it('surfaces string provider error bodies', async () => {
  829. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  830. code: '400',
  831. error: 'Argument not supported: metadata',
  832. }), { status: 400 }))
  833. const client = createModelClient(providerConfig, { fetch: fetchMock })
  834. await expect(client.create({
  835. messages: [{ role: 'user', content: 'Hello' }],
  836. })).rejects.toMatchObject({
  837. message: 'Argument not supported: metadata',
  838. statusCode: 400,
  839. })
  840. })
  841. it('surfaces Codex detail error bodies', async () => {
  842. const fetchMock = vi.fn(async () => new Response(JSON.stringify({
  843. detail: 'Unsupported parameter: max_output_tokens',
  844. }), { status: 400 }))
  845. const client = createModelClient(providerConfig, { fetch: fetchMock })
  846. await expect(client.create({
  847. messages: [{ role: 'user', content: 'Hello' }],
  848. })).rejects.toMatchObject({
  849. message: 'Unsupported parameter: max_output_tokens',
  850. statusCode: 400,
  851. })
  852. })
  853. })
粤ICP备19079148号