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:
decolua
2026-02-02 19:45:12 +07:00
parent 1b14c9d66b
commit 0a28f9f924
25 changed files with 1276 additions and 151 deletions
+8 -2
View File
@@ -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);
+7 -1
View File
@@ -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":
+7 -7
View File
@@ -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({
+36 -1
View File
@@ -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
View File
@@ -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