Files
hermes-web-ui/tests/ekko-agent/model-request.test.ts
ekko cd15791d3f [codex] Add global Ekko runtime context handling (#1950)
* Add global Ekko runtime context handling

* Pass managed MCP servers to Ekko runs

* Register MCP tools in Ekko runtime

* Watch Ekko agent sources in dev

* Document Ekko chat chain changes
2026-07-05 19:40:34 +08:00

390 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
AnthropicMessagesModelClient,
ModelProviderError,
ModelProviderRegistry,
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('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,
},
})
expect(response).toMatchObject({
id: 'chatcmpl_1',
model: 'deepseek-chat',
content: 'Done.',
finishReason: 'tool_calls',
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
},
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 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,
context: { responseId: 'resp_previous' },
})
expect(payload).toMatchObject({
model: 'gpt-4.1',
instructions: 'Be direct.',
input: [{ role: 'user', content: 'Search docs.' }],
max_output_tokens: 500,
previous_response_id: 'resp_previous',
tools: [{ type: 'function', name: 'search' }],
})
})
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('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>)
})
})