moving
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { openaiChat } from '../src/providers/openai-compat.js';
|
||||
import {
|
||||
isIncompleteCompletionError,
|
||||
openaiChat,
|
||||
parseOpenAiTextCompletion,
|
||||
} from '../src/providers/openai-compat.js';
|
||||
import type { ResolvedModel } from '../src/model-router.js';
|
||||
|
||||
const resolved: ResolvedModel = {
|
||||
@@ -9,10 +13,13 @@ const resolved: ResolvedModel = {
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
};
|
||||
|
||||
function okBody(content = 'hi') {
|
||||
function okBody(content: string | null = 'hi', finishReason: string | null | 'missing' = 'stop') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content } }],
|
||||
choices: [{
|
||||
message: { content },
|
||||
...(finishReason === 'missing' ? {} : { finish_reason: finishReason }),
|
||||
}],
|
||||
model: 'gpt-4o-mini',
|
||||
usage: { prompt_tokens: 12, completion_tokens: 5 },
|
||||
}),
|
||||
@@ -30,6 +37,117 @@ describe('openaiChat', () => {
|
||||
expect(res.usage).toEqual({ input_tokens: 12, output_tokens: 5 });
|
||||
});
|
||||
|
||||
it.each(['missing', null, 'length', 'content_filter', 'tool_calls'])(
|
||||
'rejects a 200 response with non-final finish reason %s without replay',
|
||||
async (finishReason) => {
|
||||
const fetchImpl = vi.fn(async () => okBody('Partial content', finishReason)) as unknown as typeof fetch;
|
||||
|
||||
await expect(openaiChat(
|
||||
resolved,
|
||||
[{ role: 'user', content: 'hey' }],
|
||||
undefined,
|
||||
{ fetchImpl, sleepImpl: noSleep },
|
||||
)).rejects.toMatchObject({
|
||||
code: 'INCOMPLETE_COMPLETION',
|
||||
usage: { inputTokens: 12, outputTokens: 5 },
|
||||
message: expect.stringMatching(/finish_reason=.*partial content was rejected/i),
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, '', ' '])('rejects stop with unusable assistant text %s', async (content) => {
|
||||
const fetchImpl = vi.fn(async () => okBody(content, 'stop')) as unknown as typeof fetch;
|
||||
|
||||
await expect(openaiChat(
|
||||
resolved,
|
||||
[{ role: 'user', content: 'hey' }],
|
||||
undefined,
|
||||
{ fetchImpl, sleepImpl: noSleep },
|
||||
)).rejects.toMatchObject({
|
||||
code: 'INCOMPLETE_COMPLETION',
|
||||
usage: { inputTokens: 12, outputTokens: 5 },
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects stop with missing assistant text or tool calls without replay', async () => {
|
||||
const payloads = [
|
||||
{ choices: [{ finish_reason: 'stop', message: {} }] },
|
||||
{
|
||||
choices: [{
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
content: 'Text plus an unsupported tool call.',
|
||||
tool_calls: [{ id: 'call_1' }],
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
...payload,
|
||||
model: 'gpt-4o-mini',
|
||||
usage: { prompt_tokens: 12, completion_tokens: 5 },
|
||||
}), { status: 200 })) as unknown as typeof fetch;
|
||||
|
||||
await expect(openaiChat(
|
||||
resolved,
|
||||
[{ role: 'user', content: 'hey' }],
|
||||
undefined,
|
||||
{ fetchImpl, sleepImpl: noSleep },
|
||||
)).rejects.toMatchObject({ code: 'INCOMPLETE_COMPLETION' });
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies an empty paid choice set as incomplete and preserves usage', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
choices: [],
|
||||
model: 'gpt-4o-mini',
|
||||
usage: { prompt_tokens: 12, completion_tokens: 5 },
|
||||
}), { status: 200 })) as unknown as typeof fetch;
|
||||
|
||||
await expect(openaiChat(
|
||||
resolved,
|
||||
[{ role: 'user', content: 'hey' }],
|
||||
undefined,
|
||||
{ fetchImpl, sleepImpl: noSleep },
|
||||
)).rejects.toMatchObject({
|
||||
code: 'INCOMPLETE_COMPLETION',
|
||||
usage: { inputTokens: 12, outputTokens: 5 },
|
||||
message: expect.stringMatching(/missing completion choice/i),
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects a null JSON response without replay', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('null', { status: 200 })) as unknown as typeof fetch;
|
||||
|
||||
await expect(openaiChat(
|
||||
resolved,
|
||||
[{ role: 'user', content: 'hey' }],
|
||||
undefined,
|
||||
{ fetchImpl, sleepImpl: noSleep },
|
||||
)).rejects.toMatchObject({ code: 'INCOMPLETE_COMPLETION' });
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('classifies malformed JSON from a successful HTTP response as incomplete without replay', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('{', { status: 200 })) as unknown as typeof fetch;
|
||||
|
||||
await expect(openaiChat(
|
||||
resolved,
|
||||
[{ role: 'user', content: 'hey' }],
|
||||
undefined,
|
||||
{ fetchImpl, sleepImpl: noSleep },
|
||||
)).rejects.toMatchObject({ code: 'INCOMPLETE_COMPLETION' });
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('passes an AbortSignal (timeout) to fetch', async () => {
|
||||
const fetchImpl = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
@@ -92,3 +210,40 @@ describe('openaiChat', () => {
|
||||
expect((fetchImpl as unknown as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOpenAiTextCompletion', () => {
|
||||
it('returns complete text and normalized token/cost usage', () => {
|
||||
expect(parseOpenAiTextCompletion({
|
||||
choices: [{ finish_reason: 'stop', message: { content: 'Complete.' } }],
|
||||
model: 'test-model',
|
||||
usage: { prompt_tokens: 9, completion_tokens: 4, total_cost: 0.0123 },
|
||||
})).toEqual({
|
||||
content: 'Complete.',
|
||||
model: 'test-model',
|
||||
usage: { inputTokens: 9, outputTokens: 4, totalCostUsd: 0.0123 },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves paid usage on an incomplete provider error payload', () => {
|
||||
try {
|
||||
parseOpenAiTextCompletion({
|
||||
error: { message: 'provider interrupted' },
|
||||
usage: { prompt_tokens: 8, completion_tokens: 3, total_cost: 0.004 },
|
||||
});
|
||||
throw new Error('expected parser to reject');
|
||||
} catch (error) {
|
||||
expect(isIncompleteCompletionError(error)).toBe(true);
|
||||
expect(error).toMatchObject({
|
||||
code: 'INCOMPLETE_COMPLETION',
|
||||
usage: { inputTokens: 8, outputTokens: 3, totalCostUsd: 0.004 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ finish_reason: 'stop', message: { content: 'text', refusal: 'blocked' } },
|
||||
{ finish_reason: 'stop', message: { content: 'text', tool_calls: { malformed: true } } },
|
||||
])('rejects terminal payload mismatches', (choice) => {
|
||||
expect(() => parseOpenAiTextCompletion({ choices: [choice] })).toThrow(/rejected/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user