Vendor LLMmap / llm-verify / llm-fingerprint-detector under bash/fingerprint/tools so the three fingerprint benchmarks run with only /data1/eval mounted (no /data1/xii dependency): - run.py DEFAULT_TOOLS_ROOT prefers builtin tools/, falls back to /data1/xii - exclude .git / node_modules / template backups - detector dist/ (pre-built) retained; node_modules not needed at runtime
216 lines
7.0 KiB
TypeScript
216 lines
7.0 KiB
TypeScript
/**
|
|
* Minimal HTTP layer over `POST {baseUrl}/chat/completions`:
|
|
* timeout + exponential backoff on 429/5xx/timeouts + AbortSignal
|
|
* pass-through + error classification. Uses the global `fetch`
|
|
* (Node ≥ 18 built-in, or any browser).
|
|
*
|
|
* The API key only ever appears in the Authorization header of the request
|
|
* to the endpoint under test; it is never logged or sent anywhere else.
|
|
*/
|
|
|
|
import { DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS } from './constants.js'
|
|
import type { ResolvedEndpoint, SampleUsage } from './types.js'
|
|
|
|
export type ProbeErrorKind = 'network' | 'auth' | 'http' | 'timeout' | 'aborted'
|
|
|
|
export class ProbeRequestError extends Error {
|
|
readonly kind: ProbeErrorKind
|
|
readonly status: number | null
|
|
|
|
constructor(kind: ProbeErrorKind, message: string, status: number | null = null) {
|
|
super(message)
|
|
this.name = 'ProbeRequestError'
|
|
this.kind = kind
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
export interface ChatCompletionResult {
|
|
content: string
|
|
usage: SampleUsage | null
|
|
latencyMs: number
|
|
status: number
|
|
}
|
|
|
|
interface ChatCompletionUsagePayload {
|
|
prompt_tokens?: number
|
|
completion_tokens?: number
|
|
reasoning_tokens?: number
|
|
completion_tokens_details?: { reasoning_tokens?: number }
|
|
}
|
|
|
|
function parseUsage(payload: ChatCompletionUsagePayload | undefined | null): SampleUsage | null {
|
|
if (!payload) return null
|
|
return {
|
|
promptTokens: typeof payload.prompt_tokens === 'number' ? payload.prompt_tokens : null,
|
|
completionTokens:
|
|
typeof payload.completion_tokens === 'number' ? payload.completion_tokens : null,
|
|
reasoningTokens:
|
|
typeof payload.completion_tokens_details?.reasoning_tokens === 'number'
|
|
? payload.completion_tokens_details.reasoning_tokens
|
|
: typeof payload.reasoning_tokens === 'number'
|
|
? payload.reasoning_tokens
|
|
: null,
|
|
}
|
|
}
|
|
|
|
/** `content` is a string in the OpenAI schema, but some gateways send part arrays. */
|
|
function extractContent(message: unknown): string {
|
|
if (!message || typeof message !== 'object') return ''
|
|
const content = (message as { content?: unknown }).content
|
|
if (typeof content === 'string') return content
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((part) =>
|
|
typeof part === 'string'
|
|
? part
|
|
: typeof (part as { text?: unknown })?.text === 'string'
|
|
? (part as { text: string }).text
|
|
: '',
|
|
)
|
|
.join('')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export interface ChatCompletionRequest {
|
|
endpoint: ResolvedEndpoint
|
|
systemPrompt: string
|
|
userPrompt: string
|
|
temperature: number
|
|
maxTokens: number
|
|
extraBody: Record<string, unknown>
|
|
signal?: AbortSignal
|
|
timeoutMs?: number
|
|
retries?: number
|
|
}
|
|
|
|
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
resolve()
|
|
}, ms)
|
|
const onAbort = () => {
|
|
clearTimeout(timer)
|
|
reject(new ProbeRequestError('aborted', 'Aborted'))
|
|
}
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
/**
|
|
* One chat/completions call with retries. Retried: 429, 5xx, timeouts.
|
|
* Not retried: transport errors and 401/403 — those are classified and
|
|
* re-thrown for the caller to decide.
|
|
*/
|
|
export async function fetchChatCompletion(
|
|
request: ChatCompletionRequest,
|
|
): Promise<ChatCompletionResult> {
|
|
const retries = request.retries ?? DEFAULT_MAX_RETRIES
|
|
const timeoutMs = request.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
let lastError: ProbeRequestError | null = null
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
if (request.signal?.aborted) throw new ProbeRequestError('aborted', 'Aborted')
|
|
|
|
const timeoutController = new AbortController()
|
|
const timer = setTimeout(() => timeoutController.abort(), timeoutMs)
|
|
const onOuterAbort = () => timeoutController.abort()
|
|
request.signal?.addEventListener('abort', onOuterAbort, { once: true })
|
|
|
|
const startedAt = performance.now()
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...request.endpoint.headers,
|
|
}
|
|
if (request.endpoint.apiKey) {
|
|
headers.Authorization = `Bearer ${request.endpoint.apiKey}`
|
|
}
|
|
const response = await fetch(`${request.endpoint.baseUrl}/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: request.endpoint.model,
|
|
temperature: request.temperature,
|
|
max_tokens: request.maxTokens,
|
|
stream: false,
|
|
messages: [
|
|
{ role: 'system', content: request.systemPrompt },
|
|
{ role: 'user', content: request.userPrompt },
|
|
],
|
|
...request.extraBody,
|
|
}),
|
|
signal: timeoutController.signal,
|
|
})
|
|
const latencyMs = performance.now() - startedAt
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new ProbeRequestError(
|
|
'auth',
|
|
`HTTP ${response.status} — API key rejected`,
|
|
response.status,
|
|
)
|
|
}
|
|
if (response.status === 429 || response.status >= 500) {
|
|
const retryAfterHeader = response.headers.get('retry-after')
|
|
const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : NaN
|
|
lastError = new ProbeRequestError('http', `HTTP ${response.status}`, response.status)
|
|
if (attempt < retries) {
|
|
const backoff = Number.isFinite(retryAfterMs)
|
|
? retryAfterMs
|
|
: 800 * 2 ** attempt + Math.random() * 400
|
|
await delay(backoff, request.signal)
|
|
continue
|
|
}
|
|
throw lastError
|
|
}
|
|
if (!response.ok) {
|
|
let detail = ''
|
|
try {
|
|
detail = (await response.text()).slice(0, 300)
|
|
} catch {
|
|
// Ignore body read failures.
|
|
}
|
|
throw new ProbeRequestError(
|
|
'http',
|
|
`HTTP ${response.status} ${detail}`.trim(),
|
|
response.status,
|
|
)
|
|
}
|
|
|
|
const payload = (await response.json()) as {
|
|
choices?: Array<{ message?: unknown }>
|
|
usage?: ChatCompletionUsagePayload
|
|
}
|
|
return {
|
|
content: extractContent(payload?.choices?.[0]?.message),
|
|
usage: parseUsage(payload?.usage),
|
|
latencyMs,
|
|
status: response.status,
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ProbeRequestError) throw error
|
|
if (request.signal?.aborted) throw new ProbeRequestError('aborted', 'Aborted')
|
|
if (timeoutController.signal.aborted) {
|
|
lastError = new ProbeRequestError('timeout', `Request timed out after ${timeoutMs}ms`)
|
|
} else {
|
|
lastError = new ProbeRequestError(
|
|
'network',
|
|
error instanceof Error ? error.message : 'Network error',
|
|
)
|
|
}
|
|
if (lastError.kind === 'timeout' && attempt < retries) {
|
|
continue
|
|
}
|
|
throw lastError
|
|
} finally {
|
|
clearTimeout(timer)
|
|
request.signal?.removeEventListener('abort', onOuterAbort)
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new ProbeRequestError('network', 'Unknown error')
|
|
}
|