mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: Add OpenAI-compatible provider nodes
- Support multiple OpenAI-compatible providers with custom prefix/baseUrl - Add provider nodes CRUD (create/read/update/delete) - URL building: baseUrl + /chat/completions or /responses - Model import from /models endpoint - API key validation via /models - Usage type safety across all translators - OAuth token auto-refresh for expired tokens
This commit is contained in:
@@ -19,7 +19,13 @@ export class BaseExecutor {
|
||||
return this.getBaseUrls().length || 1;
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0) {
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
const baseUrls = this.getBaseUrls();
|
||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||
}
|
||||
@@ -73,7 +79,7 @@ export class BaseExecutor {
|
||||
let lastStatus = 0;
|
||||
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex);
|
||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0) {
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
switch (this.provider) {
|
||||
case "claude":
|
||||
case "glm":
|
||||
|
||||
@@ -189,10 +189,10 @@ function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
||||
* Handles different provider response formats
|
||||
*/
|
||||
function extractUsageFromResponse(responseBody, provider) {
|
||||
if (!responseBody) return null;
|
||||
if (!responseBody || typeof responseBody !== 'object') return null;
|
||||
|
||||
// OpenAI format
|
||||
if (responseBody.usage) {
|
||||
if (responseBody.usage && typeof responseBody.usage === 'object') {
|
||||
return {
|
||||
prompt_tokens: responseBody.usage.prompt_tokens || 0,
|
||||
completion_tokens: responseBody.usage.completion_tokens || 0,
|
||||
@@ -202,7 +202,7 @@ function extractUsageFromResponse(responseBody, provider) {
|
||||
}
|
||||
|
||||
// Claude format
|
||||
if (responseBody.usage?.input_tokens !== undefined || responseBody.usage?.output_tokens !== undefined) {
|
||||
if (responseBody.usage && typeof responseBody.usage === 'object' && (responseBody.usage.input_tokens !== undefined || responseBody.usage.output_tokens !== undefined)) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usage.input_tokens || 0,
|
||||
completion_tokens: responseBody.usage.output_tokens || 0,
|
||||
@@ -212,7 +212,7 @@ function extractUsageFromResponse(responseBody, provider) {
|
||||
}
|
||||
|
||||
// Gemini format
|
||||
if (responseBody.usageMetadata) {
|
||||
if (responseBody.usageMetadata && typeof responseBody.usageMetadata === 'object') {
|
||||
return {
|
||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||
@@ -411,11 +411,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
await onRequestSuccess();
|
||||
}
|
||||
|
||||
// Log usage for non-streaming responses
|
||||
// Log usage for non-streaming responses
|
||||
const usage = extractUsageFromResponse(responseBody, provider);
|
||||
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => { });
|
||||
if (usage) {
|
||||
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage.prompt_tokens || 0} | out=${usage.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
|
||||
if (usage && typeof usage === 'object') {
|
||||
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
|
||||
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||
|
||||
saveRequestUsage({
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { PROVIDERS } from "../config/constants.js";
|
||||
|
||||
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
const OPENAI_COMPATIBLE_DEFAULTS = {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
};
|
||||
|
||||
function isOpenAICompatible(provider) {
|
||||
return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
function getOpenAICompatibleType(provider) {
|
||||
if (!isOpenAICompatible(provider)) return "chat";
|
||||
return provider.includes("responses") ? "responses" : "chat";
|
||||
}
|
||||
|
||||
function buildOpenAICompatibleUrl(baseUrl, apiType) {
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const path = apiType === "responses" ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
|
||||
// Detect request format from body structure
|
||||
export function detectFormat(body) {
|
||||
// OpenAI Responses API: has input[] array instead of messages[]
|
||||
@@ -76,6 +96,14 @@ export function detectFormat(body) {
|
||||
|
||||
// Get provider config
|
||||
export function getProviderConfig(provider) {
|
||||
if (isOpenAICompatible(provider)) {
|
||||
const apiType = getOpenAICompatibleType(provider);
|
||||
return {
|
||||
...PROVIDERS.openai,
|
||||
format: apiType === "responses" ? "openai-responses" : "openai",
|
||||
baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl,
|
||||
};
|
||||
}
|
||||
return PROVIDERS[provider] || PROVIDERS.openai;
|
||||
}
|
||||
|
||||
@@ -87,6 +115,11 @@ export function getProviderFallbackCount(provider) {
|
||||
|
||||
// Build provider URL
|
||||
export function buildProviderUrl(provider, model, stream = true, options = {}) {
|
||||
if (isOpenAICompatible(provider)) {
|
||||
const apiType = getOpenAICompatibleType(provider);
|
||||
const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl;
|
||||
return buildOpenAICompatibleUrl(baseUrl, apiType);
|
||||
}
|
||||
const config = getProviderConfig(provider);
|
||||
|
||||
switch (provider) {
|
||||
@@ -215,6 +248,9 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
||||
|
||||
// Get target format for provider
|
||||
export function getTargetFormat(provider) {
|
||||
if (isOpenAICompatible(provider)) {
|
||||
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
|
||||
}
|
||||
const config = getProviderConfig(provider);
|
||||
return config.format || "openai";
|
||||
}
|
||||
@@ -242,4 +278,3 @@ export function normalizeThinkingConfig(body) {
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,10 +110,10 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_stop": {
|
||||
case "message_stop": {
|
||||
if (!state.finishReasonSent) {
|
||||
const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop");
|
||||
const usageObj = state.usage ? {
|
||||
const usageObj = (state.usage && typeof state.usage === 'object') ? {
|
||||
usage: {
|
||||
prompt_tokens: state.usage.input_tokens || 0,
|
||||
completion_tokens: state.usage.output_tokens || 0,
|
||||
|
||||
@@ -181,9 +181,9 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
state.finishReason = finishReason;
|
||||
}
|
||||
|
||||
// Usage metadata
|
||||
// Usage metadata
|
||||
const usage = response.usageMetadata || chunk.usageMetadata;
|
||||
if (usage) {
|
||||
if (usage && typeof usage === 'object') {
|
||||
const promptTokens = (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0);
|
||||
state.usage = {
|
||||
prompt_tokens: promptTokens,
|
||||
|
||||
@@ -164,14 +164,16 @@ export function convertKiroToOpenAI(chunk, state) {
|
||||
return openaiChunk;
|
||||
}
|
||||
|
||||
// Handle usage events
|
||||
// Handle usage events
|
||||
if (eventType === "usageEvent" || data.usageEvent) {
|
||||
const usage = data.usageEvent || data;
|
||||
state.usage = {
|
||||
prompt_tokens: usage.inputTokens || 0,
|
||||
completion_tokens: usage.outputTokens || 0,
|
||||
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0)
|
||||
};
|
||||
if (usage && typeof usage === 'object') {
|
||||
state.usage = {
|
||||
prompt_tokens: usage.inputTokens || 0,
|
||||
completion_tokens: usage.outputTokens || 0,
|
||||
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+40
-17
@@ -13,45 +13,68 @@ function getTimeString() {
|
||||
|
||||
// Extract usage from any format (Claude, OpenAI, Gemini, Responses API)
|
||||
function extractUsage(chunk) {
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
|
||||
// Claude format (message_delta event)
|
||||
if (chunk.type === "message_delta" && chunk.usage) {
|
||||
return {
|
||||
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === 'object') {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: chunk.usage.input_tokens || 0,
|
||||
completion_tokens: chunk.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens
|
||||
};
|
||||
});
|
||||
}
|
||||
// OpenAI Responses API format (response.completed or response.done)
|
||||
if ((chunk.type === "response.completed" || chunk.type === "response.done") && chunk.response?.usage) {
|
||||
if ((chunk.type === "response.completed" || chunk.type === "response.done") && chunk.response?.usage && typeof chunk.response.usage === 'object') {
|
||||
const usage = chunk.response.usage;
|
||||
return {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.output_tokens || usage.completion_tokens || 0,
|
||||
cached_tokens: usage.input_tokens_details?.cached_tokens,
|
||||
reasoning_tokens: usage.output_tokens_details?.reasoning_tokens
|
||||
};
|
||||
});
|
||||
}
|
||||
// OpenAI format
|
||||
if (chunk.usage?.prompt_tokens !== undefined) {
|
||||
return {
|
||||
if (chunk.usage && typeof chunk.usage === 'object' && chunk.usage.prompt_tokens !== undefined) {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: chunk.usage.prompt_tokens,
|
||||
completion_tokens: chunk.usage.completion_tokens || 0,
|
||||
cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens,
|
||||
reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens
|
||||
};
|
||||
});
|
||||
}
|
||||
// Gemini format
|
||||
if (chunk.usageMetadata) {
|
||||
return {
|
||||
if (chunk.usageMetadata && typeof chunk.usageMetadata === 'object') {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: chunk.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: chunk.usageMetadata.candidatesTokenCount || 0,
|
||||
reasoning_tokens: chunk.usageMetadata.thoughtsTokenCount
|
||||
};
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeUsage(usage) {
|
||||
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
||||
|
||||
const normalized = {};
|
||||
const assignNumber = (key, value) => {
|
||||
if (value === undefined || value === null) return;
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) normalized[key] = numeric;
|
||||
};
|
||||
|
||||
assignNumber("prompt_tokens", usage?.prompt_tokens);
|
||||
assignNumber("completion_tokens", usage?.completion_tokens);
|
||||
assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens);
|
||||
assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens);
|
||||
assignNumber("cached_tokens", usage?.cached_tokens);
|
||||
assignNumber("reasoning_tokens", usage?.reasoning_tokens);
|
||||
|
||||
if (Object.keys(normalized).length === 0) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
export const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
@@ -64,11 +87,11 @@ export const COLORS = {
|
||||
|
||||
// Log usage with cache info (green color)
|
||||
function logUsage(provider, usage, model = null, connectionId = null) {
|
||||
if (!usage) return;
|
||||
if (!usage || typeof usage !== 'object') return;
|
||||
|
||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||
const inTokens = usage.prompt_tokens || 0;
|
||||
const outTokens = usage.completion_tokens || 0;
|
||||
const inTokens = usage?.prompt_tokens || 0;
|
||||
const outTokens = usage?.completion_tokens || 0;
|
||||
|
||||
let msg = `[${getTimeString()}] 📊 [USAGE] ${p} | in=${inTokens} | out=${outTokens}`;
|
||||
if (connectionId) msg += ` | account=${connectionId.slice(0, 8)}...`;
|
||||
@@ -274,7 +297,7 @@ export function createSSEStream(options = {}) {
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(sharedEncoder.encode(output));
|
||||
}
|
||||
if (usage) {
|
||||
if (usage && typeof usage === 'object') {
|
||||
logUsage(provider, usage, model, connectionId);
|
||||
} else {
|
||||
// No usage data available - still mark request as completed
|
||||
@@ -331,7 +354,7 @@ export function createSSEStream(options = {}) {
|
||||
reqLogger?.appendConvertedChunk?.(doneOutput);
|
||||
controller.enqueue(sharedEncoder.encode(doneOutput));
|
||||
|
||||
if (state?.usage) {
|
||||
if (state?.usage && typeof state.usage === 'object') {
|
||||
logUsage(state.provider || targetFormat, state.usage, model, connectionId);
|
||||
} else {
|
||||
// No usage data available - still mark request as completed
|
||||
|
||||
Reference in New Issue
Block a user