Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

287 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../../src/cache';
import { calculateMoonshotCost, createMoonshotProvider } from '../../src/providers/moonshot';
import { mockProcessEnv } from '../util/utils';
import type { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
vi.mock('../../src/cache', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/cache')>()),
fetchWithCache: vi.fn(),
}));
vi.mock('../../src/logger', () => ({
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// The provider extends OpenAiChatCompletionProvider; the Moonshot-specific
// wiring (routing, base URL, key resolution, sampling-param handling, cost) is
// what we assert here. The underlying HTTP behaviour is covered by the OpenAI
// provider's own tests.
function asChat(provider: ReturnType<typeof createMoonshotProvider>) {
return provider as unknown as OpenAiChatCompletionProvider & {
getApiUrl: () => string;
getOrganization: () => unknown;
getOpenAiBody: (prompt: string) => Promise<{ body: any; config: any }>;
toJSON: () => any;
};
}
describe('createMoonshotProvider routing', () => {
it('parses moonshot:<model>', () => {
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k');
expect(provider.id()).toBe('moonshot:moonshot-v1-8k');
expect(asChat(provider).modelName).toBe('moonshot-v1-8k');
});
it('parses moonshot:chat:<model> to the same model', () => {
const provider = createMoonshotProvider('moonshot:chat:moonshot-v1-8k');
expect(provider.id()).toBe('moonshot:moonshot-v1-8k');
expect(asChat(provider).modelName).toBe('moonshot-v1-8k');
});
it('preserves dotted Kimi version ids', () => {
const provider = createMoonshotProvider('moonshot:kimi-k2.6');
expect(asChat(provider).modelName).toBe('kimi-k2.6');
});
it('falls back to the default model for a bare prefix', () => {
expect(asChat(createMoonshotProvider('moonshot:')).modelName).toBe('kimi-k2.6');
expect(asChat(createMoonshotProvider('moonshot:chat')).modelName).toBe('kimi-k2.6');
expect(asChat(createMoonshotProvider('moonshot:chat:')).modelName).toBe('kimi-k2.6');
});
});
describe('MoonshotProvider configuration', () => {
it('points at the Moonshot base URL and key envar by default', () => {
const provider = asChat(createMoonshotProvider('moonshot:moonshot-v1-8k'));
expect(provider.config.apiBaseUrl).toBe('https://api.moonshot.ai/v1');
expect(provider.config.apiKeyEnvar).toBe('MOONSHOT_API_KEY');
expect(provider.getApiUrl()).toBe('https://api.moonshot.ai/v1');
});
it('lets the user override the base URL (e.g. the China endpoint)', () => {
const provider = asChat(
createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { apiBaseUrl: 'https://api.moonshot.cn/v1' },
}),
);
expect(provider.config.apiBaseUrl).toBe('https://api.moonshot.cn/v1');
expect(provider.getApiUrl()).toBe('https://api.moonshot.cn/v1');
});
it('passes through standard OpenAI options without dropping them', () => {
const provider = asChat(
createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { temperature: 0.2, max_tokens: 256 },
}),
);
expect(provider.config.temperature).toBe(0.2);
expect(provider.config.max_tokens).toBe(256);
});
it('reports itself as a Moonshot provider', () => {
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k');
expect(provider.toString()).toBe('[Moonshot Provider moonshot-v1-8k]');
expect(asChat(provider).toJSON()).toMatchObject({
provider: 'moonshot',
model: 'moonshot-v1-8k',
});
});
it('redacts an explicit apiKey from toJSON output', () => {
const provider = asChat(
createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { apiKey: 'sk-secret', temperature: 0.2 },
}),
);
const json = provider.toJSON();
expect(json.config.apiKey).toBeUndefined();
expect(json.config.temperature).toBe(0.2);
expect(JSON.stringify(json)).not.toContain('sk-secret');
});
});
describe('MoonshotProvider key resolution', () => {
it('resolves apiKey from config', () => {
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { apiKey: 'sk-from-config' },
});
expect((provider as any).getApiKey()).toBe('sk-from-config');
});
it('resolves apiKey from the MOONSHOT_API_KEY env var', () => {
const restore = mockProcessEnv({ MOONSHOT_API_KEY: 'sk-from-env' });
try {
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k');
expect((provider as any).getApiKey()).toBe('sk-from-env');
} finally {
restore();
}
});
it('does NOT fall back to OPENAI_API_KEY or forward the OpenAI organization', () => {
const restore = mockProcessEnv({
OPENAI_API_KEY: 'sk-openai-secret',
OPENAI_ORGANIZATION: 'org-openai-secret',
});
try {
const provider = asChat(createMoonshotProvider('moonshot:moonshot-v1-8k'));
expect((provider as any).getApiKey()).toBeUndefined();
expect(provider.getOrganization()).toBeUndefined();
} finally {
restore();
}
});
it('honours a custom apiKeyEnvar', () => {
const restore = mockProcessEnv({ CUSTOM_MOONSHOT_KEY: 'sk-custom' });
try {
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { apiKeyEnvar: 'CUSTOM_MOONSHOT_KEY' },
});
expect((provider as any).getApiKey()).toBe('sk-custom');
} finally {
restore();
}
});
});
describe('MoonshotProvider sampling-param handling', () => {
it('omits promptfoo default sampling/token params for Kimi models', async () => {
const provider = asChat(createMoonshotProvider('moonshot:kimi-k2.6'));
const { body } = await provider.getOpenAiBody('Hello');
// Kimi rejects any non-default temperature and needs the 32k server budget.
expect(body.temperature).toBeUndefined();
expect(body.max_tokens).toBeUndefined();
expect(body.top_p).toBeUndefined();
expect(body.presence_penalty).toBeUndefined();
expect(body.frequency_penalty).toBeUndefined();
});
it('preserves explicit sampling/token params for Kimi models', async () => {
const provider = asChat(
createMoonshotProvider('moonshot:kimi-k2.6', {
config: { temperature: 1, max_tokens: 2048 },
}),
);
const { body } = await provider.getOpenAiBody('Hello');
expect(body.temperature).toBe(1);
expect(body.max_tokens).toBe(2048);
});
it('maps max_completion_tokens to Moonshot max_tokens for Kimi (not the injected 1024 default)', async () => {
const provider = asChat(
createMoonshotProvider('moonshot:kimi-k2.6', {
config: { max_completion_tokens: 4096 },
}),
);
const { body } = await provider.getOpenAiBody('Hello');
// The base drops max_completion_tokens for non-reasoning models and injects
// max_tokens: 1024; map the caller's value onto Moonshot's max_tokens field.
expect(body.max_tokens).toBe(4096);
expect(body.max_completion_tokens).toBeUndefined();
});
it('does not leak OPENAI_* sampling env defaults into Kimi requests', async () => {
const restore = mockProcessEnv({
OPENAI_TOP_P: '0.5',
OPENAI_PRESENCE_PENALTY: '0.7',
OPENAI_FREQUENCY_PENALTY: '0.9',
});
try {
const provider = asChat(createMoonshotProvider('moonshot:kimi-k2.6'));
const { body } = await provider.getOpenAiBody('Hello');
expect(body.top_p).toBeUndefined();
expect(body.presence_penalty).toBeUndefined();
expect(body.frequency_penalty).toBeUndefined();
} finally {
restore();
}
});
it('keeps promptfoo deterministic defaults for moonshot-v1 generation models', async () => {
const provider = asChat(createMoonshotProvider('moonshot:moonshot-v1-8k'));
const { body } = await provider.getOpenAiBody('Hello');
// moonshot-v1 accepts arbitrary sampling, so the deterministic default stays.
expect(body.temperature).toBe(0);
expect(body.max_tokens).toBe(1024);
});
});
describe('calculateMoonshotCost', () => {
it('returns undefined when no user pricing is configured', () => {
expect(calculateMoonshotCost({}, 1000, 500)).toBeUndefined();
});
it('returns undefined when token counts are missing', () => {
expect(calculateMoonshotCost({ cost: 0.000002 }, undefined, 500)).toBeUndefined();
expect(calculateMoonshotCost({ cost: 0.000002 }, 1000, undefined)).toBeUndefined();
});
it('applies a flat cost to prompt and completion tokens', () => {
expect(calculateMoonshotCost({ cost: 0.000002 }, 1000, 500)).toBeCloseTo(0.003, 10);
});
it('lets inputCost/outputCost take precedence over the flat cost', () => {
const cost = calculateMoonshotCost(
{ cost: 0.000002, inputCost: 0.000001, outputCost: 0.000004 },
1000,
500,
);
expect(cost).toBeCloseTo(1000 * 0.000001 + 500 * 0.000004, 10);
});
it('uses per-token rates converted from per-million pricing for mixed cached input', () => {
const cost = calculateMoonshotCost(
{
inputCost: 0.95 / 1_000_000,
cacheReadCost: 0.16 / 1_000_000,
outputCost: 4 / 1_000_000,
},
1_000,
500,
400,
);
// 600 uncached input, 400 cached input, and 500 output tokens.
expect(cost).toBe(0.002634);
});
});
describe('MoonshotProvider callApi cost', () => {
const okResponse = {
data: {
choices: [{ message: { content: 'hi' }, finish_reason: 'stop' }],
usage: {
total_tokens: 15,
prompt_tokens: 10,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: 4 },
},
},
cached: false,
status: 200,
statusText: 'OK',
};
it('fills in cost from user-supplied rates (incl. cached tokens)', async () => {
vi.mocked(fetchWithCache).mockResolvedValueOnce(okResponse as any);
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { apiKey: 'k', inputCost: 0.000002, outputCost: 0.000004 },
});
const result = await provider.callApi('Say hi');
expect(result.cost).toBe(
calculateMoonshotCost({ inputCost: 0.000002, outputCost: 0.000004 }, 10, 5, 4),
);
});
it('leaves cost undefined when no pricing is configured', async () => {
vi.mocked(fetchWithCache).mockResolvedValueOnce(okResponse as any);
const provider = createMoonshotProvider('moonshot:moonshot-v1-8k', {
config: { apiKey: 'k' },
});
const result = await provider.callApi('Say hi');
expect(result.cost).toBeUndefined();
});
});