Files
qinglong/packages/ql3-ai/test/openAiCompatibleProvider.test.cjs
T

272 lines
7.2 KiB
JavaScript

const assert = require('node:assert/strict');
const test = require('node:test');
const {
OpenAiCompatibleConfigurationError,
OpenAiCompatibleProtocolError,
OpenAiCompatibleProvider,
} = require('../dist/model-gateway/openAiCompatibleProvider.js');
function context() {
return {
projectId: 'project-a',
runId: 'run-a',
stepRunId: 'step-a',
traceId: 'trace-a',
requestId: 'request-a',
deadlineAtMs: Date.now() + 10_000,
};
}
function request() {
return {
provider: 'openai-compatible',
model: 'model-a',
messages: [{ role: 'user', content: 'hello' }],
maxOutputTokens: 32,
};
}
test('remote endpoints require HTTPS while explicit loopback HTTP remains possible', () => {
assert.throws(
() =>
new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'http://models.example.test/v1/',
}),
OpenAiCompatibleConfigurationError,
);
assert.doesNotThrow(
() =>
new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'http://127.0.0.1:8080/v1/',
allowPlaintextLoopback: true,
}),
);
});
test('generate sends one bounded OpenAI-compatible request without retry', async () => {
const calls = [];
const credentialRequests = [];
let credentialDisposed = 0;
const instance = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
credentials: {
async authorizationHeader(request) {
credentialRequests.push(request);
return {
value: 'Bearer ephemeral-token',
dispose() {
credentialDisposed += 1;
},
};
},
},
async fetch(url, init) {
assert.equal(credentialDisposed, 0);
calls.push({ url: String(url), init });
return new Response(
JSON.stringify({
model: 'model-a',
choices: [
{
message: { role: 'assistant', content: 'world' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 2,
completion_tokens: 1,
total_tokens: 3,
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
},
});
const result = await instance.generate(request(), context());
assert.equal(calls.length, 1);
assert.equal(credentialDisposed, 1);
assert.deepEqual(credentialRequests, [
{
operation: 'generate',
provider: 'openai-compatible',
projectId: 'project-a',
requestId: 'request-a',
},
]);
assert.equal(calls[0].url, 'https://models.example.test/v1/chat/completions');
assert.equal(calls[0].init.headers.authorization, 'Bearer ephemeral-token');
assert.deepEqual(JSON.parse(calls[0].init.body), {
model: 'model-a',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 32,
stream: false,
});
assert.deepEqual(result, {
provider: 'openai-compatible',
model: 'model-a',
text: 'world',
finishReason: 'stop',
usage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 },
});
});
test('credential leases are disposed on network failure and malformed leases never reach fetch', async () => {
let disposed = 0;
const failing = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
credentials: {
async authorizationHeader() {
return {
value: 'Bearer short-lived',
dispose() {
disposed += 1;
},
};
},
},
async fetch() {
throw new Error('network unavailable');
},
});
await assert.rejects(
failing.generate(request(), context()),
/network unavailable/,
);
assert.equal(disposed, 1);
let calls = 0;
const malformed = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
credentials: {
async authorizationHeader() {
return {
value: 'Bearer value',
dispose() {
disposed += 1;
},
retained: true,
};
},
},
async fetch() {
calls += 1;
throw new Error('must not run');
},
});
await assert.rejects(
malformed.generate(request(), context()),
OpenAiCompatibleConfigurationError,
);
assert.equal(calls, 0);
assert.equal(disposed, 2);
});
test('adapter rejects provider identity drift before network access', async () => {
let calls = 0;
const instance = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
async fetch() {
calls += 1;
throw new Error('must not run');
},
});
await assert.rejects(
instance.generate(
{ ...request(), provider: 'another-provider' },
context(),
),
/request provider does not match the adapter/,
);
assert.equal(calls, 0);
});
test('stream parses arbitrarily split CRLF SSE and preserves final usage', async () => {
const encoder = new TextEncoder();
const payload = [
'data: {"choices":[{"delta":{"content":"hel"},"finish_reason":null}]}\r',
'\n\r\ndata: {"choices":[{"delta":{"content":"lo"},"finish_reason":"stop"}]}\r\n\r\n',
'data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\r\n\r\n',
'data: [DONE]\r\n\r\n',
];
const instance = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
async fetch(_url, init) {
assert.equal(JSON.parse(init.body).stream_options.include_usage, true);
return new Response(
new ReadableStream({
start(controller) {
for (const part of payload)
controller.enqueue(encoder.encode(part));
controller.close();
},
}),
{
status: 200,
headers: { 'content-type': 'text/event-stream' },
},
);
},
});
const chunks = [];
for await (const chunk of instance.stream(request(), context())) {
chunks.push(chunk);
}
assert.equal(chunks.map((chunk) => chunk.delta).join(''), 'hello');
assert.equal(chunks[1].finishReason, 'stop');
assert.deepEqual(chunks[2].usage, {
inputTokens: 2,
outputTokens: 1,
totalTokens: 3,
});
});
test('protocol rejects missing usage and over-limit responses', async () => {
const missingUsage = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
async fetch() {
return new Response(
JSON.stringify({
model: 'model-a',
choices: [
{
message: { content: 'world' },
finish_reason: 'stop',
},
],
}),
);
},
});
await assert.rejects(
missingUsage.generate(request(), context()),
OpenAiCompatibleProtocolError,
);
const tooLarge = new OpenAiCompatibleProvider({
type: 'openai-compatible',
baseUrl: 'https://models.example.test/v1/',
maxResponseBytes: 32,
async fetch() {
return new Response(JSON.stringify({ data: [{ id: 'x'.repeat(64) }] }));
},
});
await assert.rejects(tooLarge.listModels(), OpenAiCompatibleProtocolError);
});