Files
hermes-ui/tests/ekko-agent/model-request.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

433 lines
13 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,
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 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('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>)
})
})