mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
272 lines
7.2 KiB
JavaScript
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);
|
|
});
|