| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931 |
- import { describe, expect, it, vi } from 'vitest'
- import {
- AgentRuntime,
- AgentToolRegistry,
- AnthropicMessagesModelClient,
- ModelProviderError,
- ModelProviderRegistry,
- authorizedModelProviderPreset,
- createModelClient,
- toAnthropicMessagesPayload,
- toGeminiContentsPayload,
- normalizeOpenAIChatResponse,
- resolveModelProviderConfigs,
- toOpenAIResponsesPayload,
- toOpenAIChatPayload,
- toPromptCompletionPayload,
- } from '../../packages/ekko-agent/src/index'
- import type { ModelProviderConfig } from '../../packages/ekko-agent/src/index'
- const providerConfig: ModelProviderConfig = {
- id: 'deepseek',
- type: 'openai-compatible',
- apiKey: 'test-key',
- baseUrl: 'https://api.deepseek.com/v1',
- defaultModel: 'deepseek-chat',
- }
- describe('ekko-agent model requests', () => {
- it('completes a Codex Responses tool loop when terminal output arrays are empty', async () => {
- const encoder = new TextEncoder()
- let call = 0
- const requestBodies: Array<Record<string, any>> = []
- const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
- requestBodies.push(JSON.parse(String(init?.body)))
- call += 1
- const frames = call === 1
- ? [
- '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',
- 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_tool","status":"completed","output":[]}}\n\n',
- ]
- : [
- 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Sunny"}\n\n',
- 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_final","status":"completed","output":[]}}\n\n',
- ]
- return new Response(new ReadableStream({
- start(controller) {
- for (const frame of frames) controller.enqueue(encoder.encode(frame))
- controller.close()
- },
- }), { status: 200 })
- })
- const tools = new AgentToolRegistry()
- tools.register({
- definition: {
- name: 'weather',
- parameters: {
- type: 'object',
- properties: { city: { type: 'string' } },
- required: ['city'],
- },
- },
- async execute(input) {
- return { ok: true, content: `${input.city}: 30C` }
- },
- })
- const client = createModelClient({
- id: 'openai-codex',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- apiKey: 'token',
- baseUrl: 'https://chatgpt.com/backend-api/codex',
- defaultModel: 'gpt-5.6-terra',
- }, { fetch: fetchMock })
- const runtime = new AgentRuntime({ modelClient: client, tools })
- const result = await runtime.run({ messages: ['Check Xiamen weather'] })
- expect(result.output.content).toBe('Sunny')
- expect(fetchMock).toHaveBeenCalledTimes(2)
- expect(requestBodies[1]?.input).toEqual(expect.arrayContaining([
- expect.objectContaining({
- type: 'function_call',
- call_id: 'call_weather',
- name: 'weather',
- }),
- {
- type: 'function_call_output',
- call_id: 'call_weather',
- output: 'Xiamen: 30C',
- },
- ]))
- expect(requestBodies.every(body => body.stream === true)).toBe(true)
- })
- it('defines first-class request presets for authorized providers', () => {
- expect(authorizedModelProviderPreset('nous')).toMatchObject({
- id: 'nous',
- baseUrl: 'https://inference-api.nousresearch.com/v1',
- requestStyle: 'openai-chat',
- })
- expect(authorizedModelProviderPreset('openai-codex')).toMatchObject({
- id: 'openai-codex',
- baseUrl: 'https://chatgpt.com/backend-api/codex',
- requestStyle: 'openai-responses',
- })
- expect(authorizedModelProviderPreset('xai-oauth')).toMatchObject({
- id: 'xai-oauth',
- baseUrl: 'https://api.x.ai/v1',
- requestStyle: 'openai-responses',
- })
- expect(authorizedModelProviderPreset('qwen-oauth')).toMatchObject({
- id: 'qwen-oauth',
- baseUrl: 'https://portal.qwen.ai/v1',
- requestStyle: 'openai-chat',
- })
- })
- it.each([
- {
- provider: 'nous',
- url: 'https://inference-api.nousresearch.com/v1/chat/completions',
- response: { choices: [{ message: { content: 'Nous' }, finish_reason: 'stop' }] },
- expectedContent: 'Nous',
- },
- {
- provider: 'xai-oauth',
- url: 'https://api.x.ai/v1/responses',
- response: { output_text: 'xAI', status: 'completed' },
- expectedContent: 'xAI',
- },
- ])('sends $provider access tokens to its default endpoint', async ({
- provider,
- url,
- response,
- expectedContent,
- }) => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify(response)))
- const resolved = resolveModelProviderConfigs({
- provider,
- apiKey: 'oauth-access-token',
- model: 'test-model',
- })
- const client = createModelClient(resolved.providerConfig, { fetch: fetchMock })
- const result = await client.create({ messages: [{ role: 'user', content: 'Hello' }] })
- expect(result.content).toBe(expectedContent)
- expect(fetchMock.mock.calls[0]?.[0]).toBe(url)
- expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
- authorization: 'Bearer oauth-access-token',
- })
- })
- it('sends Codex OAuth identity headers to the ChatGPT Responses endpoint', async () => {
- const tokenPayload = Buffer.from(JSON.stringify({
- 'https://api.openai.com/auth': { chatgpt_account_id: 'account-123' },
- })).toString('base64url')
- const accessToken = `header.${tokenPayload}.signature`
- const encoder = new TextEncoder()
- const fetchMock = vi.fn(async () => new Response(new ReadableStream({
- start(controller) {
- controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Codex"}\n\n'))
- controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_codex","status":"completed","output":[]}}\n\n'))
- controller.close()
- },
- }), { status: 200 }))
- const resolved = resolveModelProviderConfigs({
- provider: 'openai-codex',
- apiKey: accessToken,
- model: 'gpt-5-codex',
- })
- const result = await createModelClient(resolved.providerConfig, { fetch: fetchMock }).create({
- messages: [{ role: 'user', content: 'Hello' }],
- metadata: { session_id: 'session-1', profile: 'default' },
- maxTokens: 1024,
- temperature: 0.2,
- })
- expect(result.content).toBe('Codex')
- expect(fetchMock.mock.calls[0]?.[0]).toBe('https://chatgpt.com/backend-api/codex/responses')
- expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
- authorization: `Bearer ${accessToken}`,
- 'user-agent': 'codex_cli_rs/0.0.0 (Ekko Agent)',
- originator: 'codex_cli_rs',
- 'ChatGPT-Account-ID': 'account-123',
- })
- const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
- expect(requestBody).toMatchObject({ store: false, stream: true })
- expect(requestBody).not.toHaveProperty('metadata')
- expect(requestBody).not.toHaveProperty('max_output_tokens')
- expect(requestBody).not.toHaveProperty('temperature')
- })
- it('keeps Codex streamed text when the terminal response output is empty', async () => {
- const encoder = new TextEncoder()
- const fetchMock = vi.fn(async () => new Response(new ReadableStream({
- start(controller) {
- controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"OK"}\n\n'))
- 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'))
- controller.close()
- },
- }), { status: 200 }))
- const client = createModelClient({
- id: 'openai-codex',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- apiKey: 'token',
- baseUrl: 'https://chatgpt.com/backend-api/codex',
- defaultModel: 'gpt-5.6-terra',
- }, { fetch: fetchMock })
- const events = []
- for await (const event of client.stream({
- messages: [{ role: 'user', content: 'Hello' }],
- })) events.push(event)
- expect(events).toContainEqual({ type: 'text-delta', text: 'OK' })
- expect(events).toContainEqual(expect.objectContaining({
- type: 'done',
- response: expect.objectContaining({ content: 'OK', finishReason: 'completed' }),
- }))
- })
- it('requests and streams Responses reasoning summaries', async () => {
- const encoder = new TextEncoder()
- const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
- expect(JSON.parse(String(init?.body))).toMatchObject({
- reasoning: {
- effort: 'high',
- summary: 'auto',
- },
- })
- return new Response(new ReadableStream({
- start(controller) {
- controller.enqueue(encoder.encode('event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","delta":"Checked the constraints."}\n\n'))
- controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Done"}\n\n'))
- controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_reasoning","status":"completed","output":[]}}\n\n'))
- controller.close()
- },
- }), { status: 200 })
- })
- const client = createModelClient({
- id: 'custom:responses',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- apiKey: 'token',
- defaultModel: 'reasoning-model',
- }, { fetch: fetchMock })
- const events = []
- for await (const event of client.stream({
- messages: [{ role: 'user', content: 'Solve it' }],
- reasoningEffort: 'high',
- reasoningSummary: 'auto',
- })) events.push(event)
- expect(events).toContainEqual({ type: 'reasoning-delta', text: 'Checked the constraints.' })
- expect(events).toContainEqual(expect.objectContaining({
- type: 'done',
- response: expect.objectContaining({
- content: 'Done',
- reasoning: 'Checked the constraints.',
- }),
- }))
- })
- it('routes Responses commentary-phase messages through the reasoning channel', async () => {
- const encoder = new TextEncoder()
- const fetchMock = vi.fn(async () => new Response(new ReadableStream({
- start(controller) {
- 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'))
- 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'))
- 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'))
- 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'))
- 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'))
- 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'))
- controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_commentary","status":"completed","output":[]}}\n\n'))
- controller.close()
- },
- }), { status: 200 }))
- const client = createModelClient({
- id: 'custom:responses',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- apiKey: 'token',
- defaultModel: 'reasoning-model',
- }, { fetch: fetchMock })
- const events = []
- for await (const event of client.stream({
- messages: [{ role: 'user', content: 'Use the model skill.' }],
- })) events.push(event)
- expect(events).toContainEqual({ type: 'reasoning-delta', text: '**Loading model skill**' })
- expect(events).not.toContainEqual({ type: 'text-delta', text: '**Loading model skill**' })
- expect(events).toContainEqual({ type: 'text-delta', text: 'Ready.' })
- expect(events).toContainEqual(expect.objectContaining({
- type: 'done',
- response: expect.objectContaining({
- content: 'Ready.',
- reasoning: '**Loading model skill**',
- }),
- }))
- })
- it('keeps non-Codex Responses create requests non-streaming', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- output_text: 'xAI',
- status: 'completed',
- })))
- const client = createModelClient({
- id: 'xai-oauth',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- apiKey: 'token',
- baseUrl: 'https://api.x.ai/v1',
- defaultModel: 'grok-4.5',
- }, { fetch: fetchMock })
- const response = await client.create({ messages: [{ role: 'user', content: 'Hello' }] })
- const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
- expect(response.content).toBe('xAI')
- expect(requestBody.stream).toBe(false)
- })
- it('emits Responses function calls from output item events', async () => {
- const encoder = new TextEncoder()
- const fetchMock = vi.fn(async () => new Response(new ReadableStream({
- start(controller) {
- 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'))
- controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_2","status":"completed","output":[]}}\n\n'))
- controller.close()
- },
- }), { status: 200 }))
- const client = createModelClient({
- id: 'openai-codex',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- apiKey: 'token',
- defaultModel: 'gpt-5.6-terra',
- }, { fetch: fetchMock })
- const events = []
- for await (const event of client.stream({ messages: [{ role: 'user', content: 'Read it' }] })) {
- events.push(event)
- }
- expect(events).toContainEqual({
- type: 'tool-call',
- toolCall: {
- id: 'call_1',
- name: 'read_file',
- arguments: { path: 'README.md' },
- rawArguments: '{"path":"README.md"}',
- },
- })
- expect(events).toContainEqual(expect.objectContaining({
- type: 'done',
- response: expect.objectContaining({
- toolCalls: [expect.objectContaining({ id: 'call_1', name: 'read_file' })],
- }),
- }))
- })
- it('omits unsupported metadata from xAI OAuth Responses requests', async () => {
- const payload = toOpenAIResponsesPayload({
- id: 'xai-oauth',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- defaultModel: 'grok-4.5',
- }, {
- messages: [{ role: 'user', content: 'Hello' }],
- metadata: { session_id: 'session-1', profile: 'default' },
- context: { responseId: 'response-from-first-turn' },
- })
- expect(payload).not.toHaveProperty('metadata')
- expect(payload.previous_response_id).toBeUndefined()
- })
- it('sends Qwen OAuth identity headers to the Portal Chat Completions endpoint', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- choices: [{ message: { content: 'Qwen' }, finish_reason: 'stop' }],
- })))
- const resolved = resolveModelProviderConfigs({
- provider: 'qwen-oauth',
- apiKey: 'qwen-access-token',
- model: 'qwen3-coder-plus',
- })
- const result = await createModelClient(resolved.providerConfig, { fetch: fetchMock }).create({
- messages: [{ role: 'user', content: 'Hello' }],
- })
- expect(result.content).toBe('Qwen')
- expect(fetchMock.mock.calls[0]?.[0]).toBe('https://portal.qwen.ai/v1/chat/completions')
- expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
- authorization: 'Bearer qwen-access-token',
- 'X-DashScope-CacheControl': 'enable',
- 'X-DashScope-AuthType': 'qwen-oauth',
- })
- expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
- messages: [{
- role: 'user',
- content: [{ type: 'text', text: 'Hello' }],
- }],
- vl_high_resolution_images: true,
- })
- })
- it('resolves provider configs from explicit api mode with inferred fallback', () => {
- const resolved = resolveModelProviderConfigs({
- provider: 'glm',
- baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
- apiKey: 'secret',
- model: 'glm-5.2',
- apiMode: 'codex_responses',
- })
- expect(resolved.providerConfig).toMatchObject({
- id: 'glm',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
- apiKey: 'secret',
- defaultModel: 'glm-5.2',
- })
- expect(resolved.fallbackProviderConfig).toMatchObject({
- requestStyle: 'openai-chat',
- defaultModel: 'glm-5.2',
- })
- })
- it('infers anthropic provider configs from anthropic URLs', () => {
- const resolved = resolveModelProviderConfigs({
- provider: 'custom',
- baseUrl: 'https://api.z.ai/api/anthropic',
- model: 'glm-5.2',
- })
- expect(resolved.providerConfig).toMatchObject({
- type: 'anthropic',
- requestStyle: 'anthropic-messages',
- })
- expect(resolved.fallbackProviderConfig).toBeUndefined()
- })
- it('converts internal requests to OpenAI-compatible chat payloads', () => {
- const payload = toOpenAIChatPayload(providerConfig, {
- messages: [
- { role: 'system', content: 'Be concise.' },
- { role: 'user', content: 'List files.' },
- ],
- tools: [
- {
- name: 'read_file',
- description: 'Read a file',
- parameters: {
- type: 'object',
- properties: {
- path: { type: 'string' },
- },
- },
- },
- ],
- temperature: 0.2,
- maxTokens: 1024,
- })
- expect(payload).toMatchObject({
- model: 'deepseek-chat',
- messages: [
- { role: 'system', content: 'Be concise.' },
- { role: 'user', content: 'List files.' },
- ],
- temperature: 0.2,
- max_tokens: 1024,
- tools: [
- {
- type: 'function',
- function: {
- name: 'read_file',
- description: 'Read a file',
- },
- },
- ],
- })
- })
- it('normalizes OpenAI-compatible responses into the internal shape', () => {
- const response = normalizeOpenAIChatResponse('deepseek', {
- id: 'chatcmpl_1',
- model: 'deepseek-chat',
- choices: [
- {
- message: {
- content: 'Done.',
- tool_calls: [
- {
- id: 'call_1',
- type: 'function',
- function: {
- name: 'read_file',
- arguments: '{"path":"README.md"}',
- },
- },
- ],
- },
- finish_reason: 'tool_calls',
- },
- ],
- usage: {
- prompt_tokens: 10,
- completion_tokens: 5,
- total_tokens: 15,
- prompt_tokens_details: { cached_tokens: 7 },
- completion_tokens_details: { reasoning_tokens: 2 },
- },
- })
- expect(response).toMatchObject({
- id: 'chatcmpl_1',
- model: 'deepseek-chat',
- content: 'Done.',
- finishReason: 'tool_calls',
- usage: {
- inputTokens: 3,
- outputTokens: 5,
- totalTokens: 15,
- cacheReadTokens: 7,
- reasoningTokens: 2,
- },
- toolCalls: [
- {
- id: 'call_1',
- name: 'read_file',
- arguments: { path: 'README.md' },
- },
- ],
- })
- })
- it('creates OpenAI-compatible clients through the registry', () => {
- const registry = new ModelProviderRegistry()
- registry.register(providerConfig)
- const client = registry.create('deepseek', {
- fetch: vi.fn(),
- })
- expect(client.provider).toBe('deepseek')
- expect(client.requestStyle).toBe('openai-chat')
- expect(client.capabilities.tools).toBe(true)
- expect(registry.list()).toHaveLength(1)
- })
- it('creates clients for every supported request style', () => {
- expect(createModelClient({
- id: 'openai-responses',
- type: 'openai',
- requestStyle: 'openai-responses',
- defaultModel: 'gpt-4.1',
- }).requestStyle).toBe('openai-responses')
- expect(createModelClient({
- id: 'claude',
- type: 'anthropic',
- defaultModel: 'claude-sonnet',
- }).requestStyle).toBe('anthropic-messages')
- expect(createModelClient({
- id: 'gemini',
- type: 'gemini',
- defaultModel: 'gemini-2.5-pro',
- }).requestStyle).toBe('gemini-contents')
- expect(createModelClient({
- id: 'legacy',
- type: 'custom',
- requestStyle: 'prompt-completion',
- defaultModel: 'legacy-text',
- }).requestStyle).toBe('prompt-completion')
- expect(createModelClient({
- id: 'runtime',
- type: 'custom',
- defaultModel: 'runtime-agent',
- }).requestStyle).toBe('custom-runtime')
- })
- it('converts internal requests to self-contained OpenAI Responses payloads', () => {
- const payload = toOpenAIResponsesPayload({
- id: 'openai',
- type: 'openai',
- requestStyle: 'openai-responses',
- defaultModel: 'gpt-4.1',
- }, {
- messages: [
- { role: 'system', content: 'Be direct.' },
- { role: 'user', content: 'Search docs.' },
- ],
- tools: [{ name: 'search', parameters: { type: 'object' } }],
- maxTokens: 500,
- reasoningEffort: 'medium',
- reasoningSummary: 'auto',
- context: { responseId: 'resp_previous' },
- })
- expect(payload).toMatchObject({
- model: 'gpt-4.1',
- instructions: 'Be direct.',
- input: [{ role: 'user', content: 'Search docs.' }],
- max_output_tokens: 500,
- reasoning: { effort: 'medium', summary: 'auto' },
- tools: [{ type: 'function', name: 'search' }],
- store: false,
- })
- expect(payload).not.toHaveProperty('previous_response_id')
- })
- it('replays Responses tool calls and results with native input item types', () => {
- const payload = toOpenAIResponsesPayload({
- id: 'openai-codex',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- defaultModel: 'gpt-5.6-terra',
- }, {
- messages: [
- { role: 'user', content: 'Check the weather.' },
- {
- role: 'assistant',
- content: '',
- toolCalls: [{
- id: 'call_weather',
- name: 'web_search',
- arguments: { query: 'Xiamen weather today' },
- rawArguments: '{"query":"Xiamen weather today"}',
- }],
- },
- {
- role: 'tool',
- toolCallId: 'call_weather',
- name: 'web_search',
- content: 'Sunny, 30°C',
- },
- ],
- })
- expect(payload.input).toEqual([
- { role: 'user', content: 'Check the weather.' },
- {
- type: 'function_call',
- call_id: 'call_weather',
- name: 'web_search',
- arguments: '{"query":"Xiamen weather today"}',
- },
- {
- type: 'function_call_output',
- call_id: 'call_weather',
- output: 'Sunny, 30°C',
- },
- ])
- })
- it('omits invalid empty-name tool history from Responses replay', () => {
- const payload = toOpenAIResponsesPayload({
- id: 'custom:fun-codex',
- type: 'openai-compatible',
- requestStyle: 'openai-responses',
- defaultModel: 'gpt-5.5',
- }, {
- messages: [
- { role: 'user', content: 'Check the weather.' },
- {
- role: 'assistant',
- content: '',
- toolCalls: [{
- id: 'call_invalid',
- name: '',
- arguments: { url: 'https://example.com' },
- }],
- },
- {
- role: 'tool',
- toolCallId: 'call_invalid',
- name: '',
- content: 'Unknown tool: ',
- },
- { role: 'user', content: 'Try again.' },
- ],
- })
- expect(payload.input).toEqual([
- { role: 'user', content: 'Check the weather.' },
- { role: 'user', content: 'Try again.' },
- ])
- })
- it('converts internal requests to Anthropic Messages payloads', () => {
- const payload = toAnthropicMessagesPayload({
- id: 'claude',
- type: 'anthropic',
- defaultModel: 'claude-sonnet',
- }, {
- messages: [
- { role: 'system', content: 'Use short answers.' },
- { role: 'user', content: 'Hello.' },
- ],
- tools: [{ name: 'read_file', parameters: { type: 'object' } }],
- })
- expect(payload).toMatchObject({
- model: 'claude-sonnet',
- system: 'Use short answers.',
- messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello.' }] }],
- max_tokens: 4096,
- tools: [{ name: 'read_file', input_schema: { type: 'object' } }],
- })
- })
- it('calls Anthropic-compatible /anthropic bases through /v1/messages', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- content: [{ type: 'text', text: 'OK' }],
- stop_reason: 'end_turn',
- }), { status: 200 }))
- const client = new AnthropicMessagesModelClient({
- id: 'custom:glm-anthropic',
- type: 'anthropic',
- requestStyle: 'anthropic-messages',
- baseUrl: 'https://api.z.ai/api/anthropic',
- apiKey: 'test-key',
- defaultModel: 'glm-5.2',
- }, { fetch: fetchMock })
- const response = await client.create({
- messages: [{ role: 'user', content: 'hi' }],
- })
- expect(response.content).toBe('OK')
- expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.z.ai/api/anthropic/v1/messages')
- expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
- authorization: 'Bearer test-key',
- 'x-api-key': 'test-key',
- })
- })
- it('merges Anthropic streaming input, output, and cache usage', async () => {
- const encoder = new TextEncoder()
- const fetchMock = vi.fn(async () => new Response(new ReadableStream({
- start(controller) {
- 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'))
- controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}\n\n'))
- controller.enqueue(encoder.encode('event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}\n\n'))
- controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'))
- controller.close()
- },
- }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }))
- const client = new AnthropicMessagesModelClient({
- id: 'anthropic',
- type: 'anthropic',
- requestStyle: 'anthropic-messages',
- baseUrl: 'https://api.anthropic.com',
- apiKey: 'test-key',
- defaultModel: 'claude-sonnet',
- }, { fetch: fetchMock })
- const events = []
- for await (const event of client.stream({
- messages: [{ role: 'user', content: 'hi' }],
- stream: true,
- })) events.push(event)
- expect(events).toContainEqual({
- type: 'usage',
- usage: {
- inputTokens: 100,
- outputTokens: 7,
- totalTokens: 107,
- cacheReadTokens: 80,
- cacheWriteTokens: 5,
- reasoningTokens: undefined,
- },
- })
- })
- it('throws Anthropic-compatible JSON error bodies even when HTTP status is 200', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- code: 500,
- msg: '404 NOT_FOUND',
- success: false,
- }), { status: 200 }))
- const client = new AnthropicMessagesModelClient({
- id: 'custom:glm-anthropic',
- type: 'anthropic',
- requestStyle: 'anthropic-messages',
- baseUrl: 'https://api.z.ai/api/anthropic/messages',
- apiKey: 'test-key',
- defaultModel: 'glm-5.2',
- }, { fetch: fetchMock })
- await expect(client.create({
- messages: [{ role: 'user', content: 'hi' }],
- })).rejects.toMatchObject({
- message: '404 NOT_FOUND',
- provider: 'custom:glm-anthropic',
- })
- })
- it('converts internal requests to Gemini Contents payloads', () => {
- const payload = toGeminiContentsPayload({
- id: 'gemini',
- type: 'gemini',
- defaultModel: 'gemini-2.5-pro',
- }, {
- messages: [
- { role: 'system', content: 'Be brief.' },
- { role: 'user', content: 'Hello.' },
- ],
- tools: [{ name: 'lookup', parameters: { type: 'object' } }],
- temperature: 0.1,
- })
- expect(payload).toMatchObject({
- systemInstruction: { parts: [{ text: 'Be brief.' }] },
- contents: [{ role: 'user', parts: [{ text: 'Hello.' }] }],
- generationConfig: { temperature: 0.1 },
- tools: [{ functionDeclarations: [{ name: 'lookup' }] }],
- })
- })
- it('converts internal requests to prompt completion payloads', () => {
- const payload = toPromptCompletionPayload({
- id: 'legacy',
- type: 'custom',
- requestStyle: 'prompt-completion',
- defaultModel: 'legacy-text',
- }, {
- messages: [
- { role: 'system', content: 'Instruction.' },
- { role: 'user', content: 'Question.' },
- ],
- maxTokens: 100,
- })
- expect(payload).toEqual({
- model: 'legacy-text',
- prompt: 'SYSTEM: Instruction.\n\nUSER: Question.',
- max_tokens: 100,
- stream: undefined,
- temperature: undefined,
- })
- })
- it('sends requests with provider headers and normalizes the response', async () => {
- const fetchMock = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response(JSON.stringify({
- id: 'chatcmpl_2',
- model: 'deepseek-chat',
- choices: [{ message: { content: 'Hello.' }, finish_reason: 'stop' }],
- })))
- const client = createModelClient(providerConfig, { fetch: fetchMock })
- const response = await client.create({
- messages: [{ role: 'user', content: 'Hello' }],
- })
- expect(response.content).toBe('Hello.')
- expect(fetchMock).toHaveBeenCalledWith(
- 'https://api.deepseek.com/v1/chat/completions',
- expect.objectContaining({
- method: 'POST',
- headers: expect.objectContaining({
- authorization: 'Bearer test-key',
- 'content-type': 'application/json',
- }),
- body: expect.stringContaining('"model":"deepseek-chat"'),
- }),
- )
- })
- it('throws normalized provider errors for failing HTTP responses', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- error: {
- message: 'rate limited',
- },
- }), { status: 429 }))
- const client = createModelClient(providerConfig, { fetch: fetchMock })
- await expect(client.create({
- messages: [{ role: 'user', content: 'Hello' }],
- })).rejects.toMatchObject({
- name: 'ModelProviderError',
- provider: 'deepseek',
- statusCode: 429,
- retryable: true,
- message: 'rate limited',
- } satisfies Partial<ModelProviderError>)
- })
- it('surfaces string provider error bodies', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- code: '400',
- error: 'Argument not supported: metadata',
- }), { status: 400 }))
- const client = createModelClient(providerConfig, { fetch: fetchMock })
- await expect(client.create({
- messages: [{ role: 'user', content: 'Hello' }],
- })).rejects.toMatchObject({
- message: 'Argument not supported: metadata',
- statusCode: 400,
- })
- })
- it('surfaces Codex detail error bodies', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({
- detail: 'Unsupported parameter: max_output_tokens',
- }), { status: 400 }))
- const client = createModelClient(providerConfig, { fetch: fetchMock })
- await expect(client.create({
- messages: [{ role: 'user', content: 'Hello' }],
- })).rejects.toMatchObject({
- message: 'Unsupported parameter: max_output_tokens',
- statusCode: 400,
- })
- })
- })
|