openpencil/scripts/ab-corpus/clients/openai-compat.ts
2026-05-03 21:00:00 +08:00

198 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Generic OpenAI-compatible chat client. Most Chinese providers (MiniMax,
* Bailian/DashScope, Zhipu/GLM, Moonshot/Kimi) expose an OpenAI-compatible
* `/chat/completions` endpoint — we route every one of them through this
* single helper so the per-provider wrappers stay 3-5 lines each and the
* harness doesn't hardcode a different fetch shape for every vendor.
*
* Treats API errors as fatal for the single call (throws); the harness's
* loop catches and records them as apply-phase failures so one bad call
* never aborts a corpus sweep.
*/
import type { TokenUsage } from '@zseven-w/pen-ai-skills';
interface ChatMessage {
role: 'system' | 'user';
content: string;
}
interface OpenAICompatResponse {
choices?: Array<{
message?: { content?: string };
finish_reason?: string;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
error?: { message?: string; code?: string };
}
/**
* Standardized provider response carrying the assistant message plus
* usage stats. All ab-corpus clients (openai-compat, codex-cli, stub)
* normalize to this shape so the harness can plumb token counts into
* ScoreRow without per-client special cases. usage = 0/0 means
* "provider didn't return usage", not "actually used 0 tokens" —
* aggregate.avgUsage skips zero rows so codex-cli columns don't
* appear free.
*/
export interface ChatCallResult {
content: string;
usage: TokenUsage;
}
export interface CallOpenAICompatArgs {
/** Full endpoint base URL, e.g. `https://api.minimaxi.com/v1`. No trailing `/chat/completions`. */
baseURL: string;
/** Bearer token. Passed verbatim as `Authorization: Bearer <key>`. */
apiKey: string;
/** Provider-specific model id, e.g. `MiniMax-M2.7`, `glm-5`, `kimi-k2.5`. */
model: string;
system: string;
user: string;
temperature?: number;
maxTokens?: number;
/** Label for error messages so "<label> HTTP 401" points at the right provider. */
label?: string;
/**
* Per-call wall-clock timeout in ms. Default 120000 (2 minutes).
* Override via `AB_CORPUS_CALL_TIMEOUT_MS` env var.
*
* Why this matters: without a timeout, a hung provider connection
* freezes the whole corpus sweep. Happened 2026-04-25 on a 48-run
* live sweep — 9/12 prompts completed, then one call on Ark Kimi
* never responded and stalled the remaining 3 prompts + 12 extra
* runs. With a timeout, the harness records it as a
* `__HARNESS_ERROR__` garbage run and moves on.
*/
timeoutMs?: number;
/**
* Retry attempts on transient errors. Default 0 (no retry). Set to 2
* for providers known to flake on empty content / timeout (Ark hosting
* GLM-5.1+Kimi-K2.6, DeepSeek api.deepseek.com). 1 is enough for
* MiniMax (saw 4 timeouts in 104 ab-v3 runs); Codex / Bailian don't
* enable this — their ab-v2 failures were model-quality (truncation,
* malformed DSL), where retry burns budget for nothing.
*
* Retried: empty `choices[0].message.content`, abort/timeout, HTTP 5xx,
* HTTP 429. NOT retried: HTTP 4xx other than 429 (auth / bad request).
*
* Backoff: exponential, 250ms × 4^attempt — 250ms before retry 1,
* 1000ms before retry 2, 4000ms before retry 3. Linear backoff was
* too tight when retries=2 (500ms then 750ms is < a typical Ark
* recovery window); exponential gives the provider room to settle.
*/
retries?: number;
}
const DEFAULT_TIMEOUT_MS = (() => {
const env = process.env.AB_CORPUS_CALL_TIMEOUT_MS;
if (env && /^\d+$/.test(env)) {
const n = Number(env);
if (n > 0) return n;
}
return 120_000;
})();
export async function callOpenAICompat(args: CallOpenAICompatArgs): Promise<ChatCallResult> {
const retries = args.retries ?? 0;
const label = args.label ?? 'openai-compat';
let lastErr: Error | undefined;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await callOpenAICompatOnce(args);
} catch (err) {
lastErr = err instanceof Error ? err : new Error(String(err));
if (attempt === retries || !isTransientError(lastErr)) {
throw lastErr;
}
const delayMs = 250 * 4 ** attempt;
console.error(
`[${label}] transient error on attempt ${attempt + 1}/${retries + 1}, retrying in ${delayMs}ms: ${truncate(
lastErr.message,
200,
)}`,
);
await sleep(delayMs);
}
}
throw lastErr ?? new Error(`${label}: callOpenAICompat exited loop without result`);
}
async function callOpenAICompatOnce(args: CallOpenAICompatArgs): Promise<ChatCallResult> {
const label = args.label ?? 'openai-compat';
const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const messages: ChatMessage[] = [
{ role: 'system', content: args.system },
{ role: 'user', content: args.user },
];
const ctrl = new AbortController();
const timer = setTimeout(
() => ctrl.abort(new Error(`${label} call exceeded ${timeoutMs}ms (model=${args.model})`)),
timeoutMs,
);
let res: Response;
try {
res = await fetch(`${args.baseURL}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${args.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: args.model,
messages,
temperature: args.temperature ?? 0.2,
max_tokens: args.maxTokens ?? 4096,
stream: false,
}),
signal: ctrl.signal,
});
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`${label} HTTP ${res.status}: ${body.slice(0, 300)}`);
}
const data: OpenAICompatResponse = await res.json();
if (data.error) {
throw new Error(`${label} API error: ${data.error.message ?? data.error.code ?? 'unknown'}`);
}
const content = data.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content.length === 0) {
throw new Error(`${label} returned no content in choices[0].message.content`);
}
return {
content,
usage: {
promptTokens: typeof data.usage?.prompt_tokens === 'number' ? data.usage.prompt_tokens : 0,
completionTokens:
typeof data.usage?.completion_tokens === 'number' ? data.usage.completion_tokens : 0,
},
};
}
function isTransientError(err: Error): boolean {
const msg = err.message ?? '';
if (/returned no content/.test(msg)) return true;
if (/exceeded \d+ms/.test(msg)) return true;
if (/HTTP 5\d\d:/.test(msg)) return true;
if (/HTTP 429:/.test(msg)) return true;
if (err.name === 'AbortError') return true;
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : `${s.slice(0, n)}…`;
}