AI_PROJECT / src /scripts /probe-llm-endpoint.mjs
chenchenaoyang's picture
Deploy from Codex
b49e26c verified
#!/usr/bin/env node
import { execFile } from "node:child_process";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_OPENAI_CANDIDATES = [
"gpt-5.4",
"gpt-5.3-codex"
];
const DEFAULT_ANTHROPIC_CANDIDATES = [
"claude-sonnet-4.6",
"google/gemini-3-flash",
];
const DEFAULT_TIMEOUT_MS = 20_000;
function joinUrl(baseUrl, path) {
return `${baseUrl.replace(/\/+$/, "")}${path}`;
}
function sanitizeBaseUrl(baseUrl) {
const trimmed = baseUrl.trim();
if (!trimmed) return trimmed;
return trimmed.replace(/\/+(v1)?\/?$/, "");
}
function unique(values) {
return [...new Set(values.filter(Boolean))];
}
async function runWithConcurrency(items, limit, worker) {
const queue = [...items];
const size = Math.min(limit, queue.length);
if (size <= 0) return;
const workers = Array.from({ length: size }, async () => {
// eslint-disable-next-line no-constant-condition
while (true) {
const item = queue.shift();
if (item === undefined) break;
// eslint-disable-next-line no-await-in-loop
await worker(item);
}
});
await Promise.all(workers);
}
async function requestJson(url, options = {}) {
const startedAt = Date.now();
const method = options.method || "GET";
const headers = options.headers || {};
const body = options.body;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const args = ["-sS", "-i", "-m", String(Math.ceil(timeoutMs / 1000)), "-X", method, url];
for (const [key, value] of Object.entries(headers)) {
args.push("-H", `${key}: ${value}`);
}
if (body) {
args.push("--data", body);
}
try {
const { stdout } = await execFileAsync("curl", args, {
maxBuffer: 1024 * 1024 * 5,
});
const normalized = stdout.replace(/\r\n/g, "\n");
const separator = normalized.lastIndexOf("\n\n");
const rawHeaders = separator >= 0 ? normalized.slice(0, separator) : "";
const text = separator >= 0 ? normalized.slice(separator + 2) : normalized;
const statusLine = rawHeaders
.split("\n")
.filter(Boolean)
.find((line) => line.startsWith("HTTP/"));
const statusMatch = statusLine?.match(/^HTTP\/\S+\s+(\d+)\s*(.*)$/);
const status = statusMatch ? Number(statusMatch[1]) : 0;
const statusText = statusMatch?.[2] || "";
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return {
ok: status >= 200 && status < 300,
status,
statusText,
json,
text,
elapsedMs: Date.now() - startedAt,
};
} catch (error) {
const detail = [error?.stdout, error?.stderr, error?.message]
.filter(Boolean)
.join("\n")
.trim();
return {
ok: false,
status: 0,
statusText: "NETWORK_ERROR",
json: null,
text: detail || String(error),
elapsedMs: Date.now() - startedAt,
};
}
}
function anthropicHeaders(apiKey) {
return {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
};
}
function openaiHeaders(apiKey) {
return {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
};
}
function extractModelsFromList(payload) {
if (!payload || !Array.isArray(payload.data)) return [];
return payload.data
.map((item) => item?.id || item?.model_name)
.filter((id) => typeof id === "string" && id.length > 0);
}
function pickSnippet(text, max = 220) {
if (!text) return "";
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
}
function hasAnthropicUsage(payload) {
return Boolean(payload?.usage && typeof payload.usage.input_tokens === "number");
}
function hasOpenAIOutput(payload) {
if (!payload) return false;
if (Array.isArray(payload.output)) return true;
return typeof payload.id === "string" && typeof payload.model === "string";
}
function hasChatCompletionOutput(payload) {
return Array.isArray(payload?.choices) && payload.choices.length > 0;
}
function extractPricingModels(payload) {
if (!payload || !Array.isArray(payload.data)) return [];
return payload.data
.map((item) => ({
model: item?.model_name,
groups: Array.isArray(item?.enable_groups) ? item.enable_groups : [],
endpoints: Array.isArray(item?.supported_endpoint_types)
? item.supported_endpoint_types
: [],
vendorId: item?.vendor_id ?? null,
}))
.filter((item) => item.model);
}
function extractPricingEndpointSummary(payload) {
if (!payload || typeof payload.supported_endpoint !== "object") return {};
return payload.supported_endpoint;
}
function extractPricingGroups(payload) {
if (!payload || typeof payload.usable_group !== "object") return {};
return payload.usable_group;
}
async function testOpenAIModels(baseUrl, apiKey, candidateModels) {
const modelsUrl = joinUrl(baseUrl, "/v1/models");
const modelsResponse = await requestJson(modelsUrl, {
method: "GET",
headers: openaiHeaders(apiKey),
});
const discoveredModels = extractModelsFromList(modelsResponse.json);
const candidates = unique([...discoveredModels, ...candidateModels]);
const reachableModels = [];
const chatReachableModels = [];
const failures = [];
const responseProbePaths = ["/responses", "/v1/responses"];
await runWithConcurrency(candidates.slice(0, 12), 3, async (model) => {
let successfulResponsesProbe = null;
let lastResponsesProbe = null;
for (const probePath of responseProbePaths) {
// eslint-disable-next-line no-await-in-loop
const responsesProbe = await requestJson(joinUrl(baseUrl, probePath), {
method: "POST",
headers: openaiHeaders(apiKey),
body: JSON.stringify({
model,
input: "Reply with OK.",
max_output_tokens: 16,
}),
});
lastResponsesProbe = responsesProbe;
if (responsesProbe.ok && hasOpenAIOutput(responsesProbe.json)) {
successfulResponsesProbe = {
path: probePath,
elapsedMs: responsesProbe.elapsedMs,
};
break;
}
}
if (successfulResponsesProbe) {
reachableModels.push({
model,
kind: "responses",
path: successfulResponsesProbe.path,
elapsedMs: successfulResponsesProbe.elapsedMs,
});
return;
}
const chatProbe = await requestJson(joinUrl(baseUrl, "/v1/chat/completions"), {
method: "POST",
headers: openaiHeaders(apiKey),
body: JSON.stringify({
model,
messages: [{ role: "user", content: "Reply with OK." }],
max_tokens: 16,
}),
});
if (chatProbe.ok && hasChatCompletionOutput(chatProbe.json)) {
chatReachableModels.push({
model,
kind: "chat_completions",
elapsedMs: chatProbe.elapsedMs,
});
} else {
failures.push({
model,
status: chatProbe.status || lastResponsesProbe?.status || 0,
responsesDetail: pickSnippet(lastResponsesProbe?.text || ""),
chatDetail: pickSnippet(chatProbe.text),
});
}
});
return {
modelsResponse,
discoveredModels,
reachableModels,
chatReachableModels,
failures,
};
}
async function collectPublicInfo(baseUrl) {
const [statusResponse, pricingResponse] = await Promise.all([
requestJson(joinUrl(baseUrl, "/api/status"), { method: "GET" }),
requestJson(joinUrl(baseUrl, "/api/pricing"), { method: "GET" }),
]);
return {
statusResponse,
pricingResponse,
pricingModels: extractPricingModels(pricingResponse.json),
pricingEndpointSummary: extractPricingEndpointSummary(pricingResponse.json),
usableGroups: extractPricingGroups(pricingResponse.json),
};
}
async function testAnthropicModels(baseUrl, apiKey, candidateModels) {
const modelsUrl = joinUrl(baseUrl, "/v1/models");
const modelsResponse = await requestJson(modelsUrl, {
method: "GET",
headers: anthropicHeaders(apiKey),
});
const discoveredModels = extractModelsFromList(modelsResponse.json);
const candidates = unique([...discoveredModels, ...candidateModels]);
const basicModels = [];
const toolModels = [];
const failures = [];
await runWithConcurrency(candidates.slice(0, 12), 3, async (model) => {
const basic = await requestJson(joinUrl(baseUrl, "/v1/messages"), {
method: "POST",
headers: anthropicHeaders(apiKey),
body: JSON.stringify({
model,
max_tokens: 32,
messages: [{ role: "user", content: "Reply with OK." }],
}),
});
if (!basic.ok || !hasAnthropicUsage(basic.json)) {
failures.push({
model,
stage: "basic",
status: basic.status,
detail: pickSnippet(basic.text),
});
return;
}
basicModels.push({
model,
elapsedMs: basic.elapsedMs,
});
const toolCall = await requestJson(joinUrl(baseUrl, "/v1/messages"), {
method: "POST",
headers: anthropicHeaders(apiKey),
body: JSON.stringify({
model,
max_tokens: 128,
tools: [
{
name: "echo",
description: "Echo input text",
input_schema: {
type: "object",
properties: {
text: { type: "string" },
},
required: ["text"],
},
},
],
messages: [
{
role: "user",
content: "Call the echo tool with text hi.",
},
],
}),
});
if (toolCall.ok && hasAnthropicUsage(toolCall.json)) {
toolModels.push({
model,
elapsedMs: toolCall.elapsedMs,
});
} else {
failures.push({
model,
stage: "tools",
status: toolCall.status,
detail: pickSnippet(toolCall.text),
});
}
});
const countTokensResponse = await requestJson(joinUrl(baseUrl, "/v1/messages/count_tokens"), {
method: "POST",
headers: anthropicHeaders(apiKey),
body: JSON.stringify({
model: basicModels[0]?.model || candidates[0] || "claude-3-5-sonnet-20241022",
messages: [{ role: "user", content: "hi" }],
}),
});
return {
modelsResponse,
discoveredModels,
basicModels,
toolModels,
failures,
countTokensResponse,
};
}
function printHeader(title) {
console.log(`\n=== ${title} ===`);
}
function printResultLine(label, value) {
console.log(`${label}: ${value}`);
}
function formatModelList(items) {
if (!items.length) return "(none)";
return items.map((item) => item.model || item).join(", ");
}
function maskSecret(value) {
if (!value) return "";
if (value.length <= 10) return "*".repeat(value.length);
return `${value.slice(0, 6)}...${value.slice(-4)}`;
}
function buildConfigSuggestions(baseUrl, apiKey, anthropicResult, openaiResult) {
const result = {
claudeCode: {
supported: anthropicResult.toolModels.length > 0,
reason:
anthropicResult.toolModels.length > 0
? "存在通过 tools 兼容性测试的模型"
: "没有任何模型通过 Anthropic tools 兼容性测试",
env: null,
},
codex: {
supported: openaiResult.reachableModels.length > 0,
reason:
openaiResult.reachableModels.length > 0
? "存在通过 responses 接口探测的模型"
: "没有任何模型通过 OpenAI responses 接口探测",
configToml: null,
authJson: null,
},
};
if (anthropicResult.toolModels.length > 0) {
const model = anthropicResult.toolModels[0].model;
result.claudeCode.env = {
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
};
}
if (openaiResult.reachableModels.length > 0) {
const model = openaiResult.reachableModels[0].model;
result.codex.configToml = [
'model_provider = "OpenAI"',
`model = "${model}"`,
`review_model = "${model}"`,
"",
"[model_providers.OpenAI]",
'name = "OpenAI"',
`base_url = "${baseUrl}"`,
'wire_api = "responses"',
"requires_openai_auth = true",
`api_key = "${apiKey}"`,
].join("\n");
result.codex.authJson = {
OPENAI_API_KEY: apiKey,
};
} else if (openaiResult.chatReachableModels.length > 0) {
const model = openaiResult.chatReachableModels[0].model;
result.codex.supported = true;
result.codex.reason = "存在通过 /v1/chat/completions 探测的模型";
result.codex.configToml = [
'model_provider = "OpenAI"',
`model = "${model}"`,
`review_model = "${model}"`,
"",
"[model_providers.OpenAI]",
'name = "OpenAI"',
`base_url = "${baseUrl}"`,
'wire_api = "chat_completions"',
"requires_openai_auth = true",
`api_key = "${apiKey}"`,
].join("\n");
result.codex.authJson = {
OPENAI_API_KEY: apiKey,
};
}
return result;
}
function printJsonBlock(value) {
console.log(JSON.stringify(value, null, 2));
}
function printConfigSuggestions(configSuggestions) {
printHeader("推荐配置");
if (configSuggestions.claudeCode.supported) {
console.log("Claude Code:支持");
printJsonBlock(configSuggestions.claudeCode.env);
} else {
console.log("Claude Code:不建议接入这个地址");
console.log(`原因:${configSuggestions.claudeCode.reason}`);
}
if (configSuggestions.codex.supported) {
console.log("\nCodex 的 config.toml:");
console.log(configSuggestions.codex.configToml);
console.log("\nCodex 的 auth.json:");
printJsonBlock(configSuggestions.codex.authJson);
} else {
console.log("\nCodex:这个地址当前没有探测到可用的 OpenAI responses 模型");
console.log(`原因:${configSuggestions.codex.reason}`);
}
}
function printModelSection(title, models) {
console.log(`${title}:`);
if (!models.length) {
console.log("- (空)");
return;
}
for (const model of models) {
console.log(`- ${model}`);
}
}
function summarizeCompatibility(anthropicResult, openaiResult) {
return {
codex:
openaiResult.reachableModels.length > 0
? "supported-via-responses"
: openaiResult.chatReachableModels.length > 0
? "supported-via-chat-completions"
: "not-confirmed",
claudeCode:
anthropicResult.toolModels.length > 0 ? "supported-via-anthropic-tools" : "not-confirmed",
};
}
function parseArgs(argv) {
const parsed = {};
for (const entry of argv) {
if (!entry.startsWith("--")) continue;
const [rawKey, ...rest] = entry.slice(2).split("=");
if (rest.length > 0) {
parsed[rawKey] = rest.join("=");
continue;
}
parsed[rawKey] = "true";
}
return parsed;
}
function printUsage() {
console.log(`Usage:
npm run probe:llm -- --baseUrl=https://example.com --key=sk-xxx
node src/scripts/probe-llm-endpoint.mjs --baseUrl=https://example.com --key=sk-xxx
Options:
--baseUrl=URL API base URL, for example https://jiuuij.de5.net
--key=VALUE API key or token
--help Show this help text
Outputs:
- 在终端输出公开信息、OpenAI / Anthropic 兼容性与推荐配置
`);
}
async function getInputs() {
const args = parseArgs(process.argv.slice(2));
let key = args.key || args.apiKey || args.sk || args.token;
let baseUrl = args.baseUrl || args.baseURL || args.url;
if (key && baseUrl) {
return {
apiKey: key.trim(),
baseUrl: sanitizeBaseUrl(baseUrl),
};
}
const rl = readline.createInterface({ input, output });
try {
if (!baseUrl) {
baseUrl = await rl.question("API base URL: ");
}
if (!key) {
key = await rl.question("API key: ");
}
} finally {
rl.close();
}
return {
apiKey: key.trim(),
baseUrl: sanitizeBaseUrl(baseUrl),
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help === "true") {
printUsage();
return;
}
const skipAnthropic = args.skipAnthropic === "true";
const skipOpenAI = args.skipOpenAI === "true";
const skipPublic = args.skipPublic === "true";
const { apiKey, baseUrl } = await getInputs();
if (!apiKey || !baseUrl) {
console.error("Both API key and base URL are required.");
process.exitCode = 1;
return;
}
console.log(`Probing endpoint: ${baseUrl}`);
const [publicInfo, anthropicResult, openaiResult] = await Promise.all([
skipPublic ? Promise.resolve({
statusResponse: { ok: false, status: 0, json: null, text: "" },
pricingResponse: { ok: false, status: 0 },
pricingModels: [],
pricingEndpointSummary: {},
usableGroups: {},
}) : collectPublicInfo(baseUrl),
skipAnthropic ? Promise.resolve({
modelsResponse: { ok: false, status: 0 },
discoveredModels: [],
basicModels: [],
toolModels: [],
failures: [],
countTokensResponse: { ok: false, status: 0, json: null, text: "" },
}) : testAnthropicModels(baseUrl, apiKey, DEFAULT_ANTHROPIC_CANDIDATES),
skipOpenAI ? Promise.resolve({
modelsResponse: { ok: false, status: 0 },
discoveredModels: [],
reachableModels: [],
chatReachableModels: [],
failures: [],
}) : testOpenAIModels(baseUrl, apiKey, DEFAULT_OPENAI_CANDIDATES),
]);
const configSuggestions = buildConfigSuggestions(
baseUrl,
apiKey,
anthropicResult,
openaiResult,
);
const compatibility = summarizeCompatibility(anthropicResult, openaiResult);
printHeader("探测摘要");
printResultLine("探测时间", new Date().toISOString());
printResultLine("Base URL", baseUrl);
printResultLine("API Key", maskSecret(apiKey));
printResultLine("Codex 兼容性", compatibility.codex);
printResultLine("Claude Code 兼容性", compatibility.claudeCode);
printHeader("公开信息");
printResultLine(
"公开 pricing",
publicInfo.pricingResponse.ok
? `成功,共 ${publicInfo.pricingModels.length} 个模型`
: `失败,状态码 ${publicInfo.pricingResponse.status}`,
);
printModelSection(
"公开 pricing 返回的模型",
publicInfo.pricingModels.map((item) => item.model),
);
printHeader("Anthropic 兼容性");
printResultLine(
"模型列表接口",
anthropicResult.modelsResponse.ok
? `成功,共 ${anthropicResult.discoveredModels.length} 个模型`
: `失败,状态码 ${anthropicResult.modelsResponse.status}`,
);
printModelSection("接口返回的模型", anthropicResult.discoveredModels);
printResultLine(
"基础 /v1/messages 可用模型",
anthropicResult.basicModels.length > 0
? formatModelList(anthropicResult.basicModels)
: "(空)",
);
printResultLine(
"可用于 Claude Code 的 tools 模型",
anthropicResult.toolModels.length > 0
? formatModelList(anthropicResult.toolModels)
: "(空)",
);
printResultLine(
"count_tokens",
anthropicResult.countTokensResponse.ok
? pickSnippet(anthropicResult.countTokensResponse.text)
: `失败,状态码 ${anthropicResult.countTokensResponse.status}`,
);
if (anthropicResult.failures.length > 0) {
console.log("最近失败记录:");
for (const failure of anthropicResult.failures.slice(0, 6)) {
console.log(
`- ${failure.model} [${failure.stage}] -> ${failure.status}: ${failure.detail}`,
);
}
}
printHeader("OpenAI 兼容性");
printResultLine(
"模型列表接口",
openaiResult.modelsResponse.ok
? `成功,共 ${openaiResult.discoveredModels.length} 个模型`
: `失败,状态码 ${openaiResult.modelsResponse.status}`,
);
printModelSection("接口返回的模型", openaiResult.discoveredModels);
printResultLine(
"可用于 Codex 的 responses 模型",
openaiResult.reachableModels.length > 0
? openaiResult.reachableModels
.map((item) => `${item.model} (${item.path})`)
.join(", ")
: "(空)",
);
printResultLine(
"可用于 Codex 的 /v1/chat/completions 模型",
openaiResult.chatReachableModels.length > 0
? formatModelList(openaiResult.chatReachableModels)
: "(空)",
);
if (openaiResult.failures.length > 0) {
console.log("最近失败记录:");
for (const failure of openaiResult.failures.slice(0, 6)) {
console.log(
`- ${failure.model} -> ${failure.status}: responses=${failure.responsesDetail}; chat=${failure.chatDetail}`,
);
}
}
// 模型汇总
printHeader("模型汇总");
printModelSection(
"公开 pricing 模型",
publicInfo.pricingModels.map((item) => item.model),
);
printModelSection(
"OpenAI 可用模型 (responses/chat_completions)",
[
...openaiResult.reachableModels.map((m) => m.model),
...openaiResult.chatReachableModels.map((m) => m.model),
],
);
printModelSection(
"Anthropic 可用模型 (tools)",
anthropicResult.toolModels.map((m) => m.model),
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});