Files
2026-07-13 13:39:12 +08:00

313 lines
9.4 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { decryptCredential, encryptCredential } from "./encryption.mjs";
const REQUIRED_PROVIDER_COLUMNS = [
["auth_type", "TEXT"],
["name", "TEXT"],
["email", "TEXT"],
["priority", "INTEGER DEFAULT 0"],
["is_active", "INTEGER DEFAULT 1"],
["access_token", "TEXT"],
["refresh_token", "TEXT"],
["expires_at", "TEXT"],
["token_expires_at", "TEXT"],
["scope", "TEXT"],
["project_id", "TEXT"],
["test_status", "TEXT"],
["error_code", "TEXT"],
["last_error", "TEXT"],
["last_error_at", "TEXT"],
["last_error_type", "TEXT"],
["last_error_source", "TEXT"],
["backoff_level", "INTEGER DEFAULT 0"],
["rate_limited_until", "TEXT"],
["health_check_interval", "INTEGER"],
["last_health_check_at", "TEXT"],
["last_tested", "TEXT"],
["api_key", "TEXT"],
["id_token", "TEXT"],
["provider_specific_data", "TEXT"],
["expires_in", "INTEGER"],
["display_name", "TEXT"],
["global_priority", "INTEGER"],
["default_model", "TEXT"],
["token_type", "TEXT"],
["consecutive_use_count", "INTEGER DEFAULT 0"],
["rate_limit_protection", "INTEGER DEFAULT 0"],
["last_used_at", "TEXT"],
['"group"', "TEXT"],
["max_concurrent", "INTEGER"],
["created_at", "TEXT"],
["updated_at", "TEXT"],
];
function ensureProviderColumns(db) {
const existingColumns = new Set(
db
.prepare("PRAGMA table_info(provider_connections)")
.all()
.map((column) => column.name)
);
const missingColumns = REQUIRED_PROVIDER_COLUMNS.filter(([name]) => {
const normalizedName = name.replaceAll('"', "");
return !existingColumns.has(normalizedName);
});
if (missingColumns.length === 0) return;
db.transaction(() => {
for (const [name, type] of missingColumns) {
db.prepare(`ALTER TABLE provider_connections ADD COLUMN ${name} ${type}`).run();
}
})();
}
export function ensureProviderSchema(db) {
db.prepare(
`CREATE TABLE IF NOT EXISTS provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
auth_type TEXT,
name TEXT,
email TEXT,
priority INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT,
token_expires_at TEXT,
scope TEXT,
project_id TEXT,
test_status TEXT,
error_code TEXT,
last_error TEXT,
last_error_at TEXT,
last_error_type TEXT,
last_error_source TEXT,
backoff_level INTEGER DEFAULT 0,
rate_limited_until TEXT,
health_check_interval INTEGER,
last_health_check_at TEXT,
last_tested TEXT,
api_key TEXT,
id_token TEXT,
provider_specific_data TEXT,
expires_in INTEGER,
display_name TEXT,
global_priority INTEGER,
default_model TEXT,
token_type TEXT,
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
"group" TEXT,
max_concurrent INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`
).run();
ensureProviderColumns(db);
db.prepare("CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider)").run();
db.prepare("CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active)").run();
db.prepare(
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority)"
).run();
}
function nextPriority(db, provider) {
const row = db
.prepare("SELECT MAX(priority) as max_priority FROM provider_connections WHERE provider = ?")
.get(provider);
return Number(row?.max_priority || 0) + 1;
}
function parseJsonObject(value) {
if (!value || typeof value !== "string") return undefined;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function rowToConnection(row) {
return {
id: row.id,
provider: row.provider,
authType: row.auth_type || "oauth",
name: row.name || row.display_name || row.email || row.provider,
email: row.email || null,
priority: row.priority || 0,
isActive: row.is_active !== 0,
apiKey: row.api_key || null,
accessToken: row.access_token || null,
refreshToken: row.refresh_token || null,
idToken: row.id_token || null,
providerSpecificData: parseJsonObject(row.provider_specific_data),
testStatus: row.test_status || "unknown",
defaultModel: row.default_model || null,
lastTested: row.last_tested || null,
lastError: row.last_error || null,
updatedAt: row.updated_at || null,
createdAt: row.created_at || null,
};
}
export function listProviderConnections(db) {
ensureProviderSchema(db);
return db
.prepare(
`SELECT * FROM provider_connections
ORDER BY provider ASC, priority ASC, updated_at DESC`
)
.all()
.map(rowToConnection);
}
export function findProviderConnection(db, selector) {
const normalized = String(selector || "")
.trim()
.toLowerCase();
if (!normalized) return null;
const connections = listProviderConnections(db);
return (
connections.find((connection) => connection.id.toLowerCase() === normalized) ||
connections.find((connection) => connection.id.toLowerCase().startsWith(normalized)) ||
connections.find((connection) => String(connection.name || "").toLowerCase() === normalized) ||
connections.find((connection) => connection.provider.toLowerCase() === normalized) ||
null
);
}
export function getProviderApiKey(connection) {
if (connection.authType !== "apikey") {
throw new Error(`Connection ${connection.name} is not an API-key provider.`);
}
if (!connection.apiKey) {
throw new Error(`Connection ${connection.name} has no API key configured.`);
}
return decryptCredential(connection.apiKey);
}
export function upsertApiKeyProviderConnection(db, input) {
ensureProviderSchema(db);
const provider = input.provider;
const name = input.name || provider;
const now = new Date().toISOString();
const existing = db
.prepare(
"SELECT id, priority FROM provider_connections WHERE provider = ? AND auth_type = 'apikey' AND name = ?"
)
.get(provider, name);
const connection = {
id: existing?.id || randomUUID(),
provider,
authType: "apikey",
name,
priority: existing?.priority || nextPriority(db, provider),
isActive: 1,
apiKey: encryptCredential(input.apiKey),
testStatus: input.testStatus || "unknown",
defaultModel: input.defaultModel || null,
providerSpecificData: input.providerSpecificData
? JSON.stringify(input.providerSpecificData)
: null,
createdAt: input.createdAt || now,
updatedAt: now,
};
db.prepare(
`INSERT INTO provider_connections (
id, provider, auth_type, name, priority, is_active, api_key, provider_specific_data,
test_status, default_model, created_at, updated_at
) VALUES (
@id, @provider, @authType, @name, @priority, @isActive, @apiKey, @providerSpecificData,
@testStatus, @defaultModel, @createdAt, @updatedAt
)
ON CONFLICT(id) DO UPDATE SET
api_key = excluded.api_key,
provider_specific_data = excluded.provider_specific_data,
test_status = excluded.test_status,
default_model = excluded.default_model,
is_active = excluded.is_active,
updated_at = excluded.updated_at`
).run(connection);
return connection;
}
export function removeProviderConnectionByProvider(db, provider) {
ensureProviderSchema(db);
const result = db
.prepare(
"DELETE FROM provider_connections WHERE provider = ? AND (auth_type = 'apikey' OR (api_key IS NOT NULL AND api_key != ''))"
)
.run(provider);
return result.changes;
}
/**
* Replace the encrypted API key for a connection and clear any cooldown state.
* `encryptedKey` must already be passed through `encryptCredential()`.
*/
export function updateProviderApiKey(db, connectionId, encryptedKey) {
ensureProviderSchema(db);
const now = new Date().toISOString();
const result = db
.prepare(
`UPDATE provider_connections
SET api_key = @apiKey,
test_status = 'unknown',
last_error = NULL,
last_error_at = NULL,
last_error_type = NULL,
last_error_source = NULL,
error_code = NULL,
rate_limited_until = NULL,
backoff_level = 0,
updated_at = @updatedAt
WHERE id = @id`
)
.run({ id: connectionId, apiKey: encryptedKey, updatedAt: now });
return result.changes;
}
export function updateProviderTestResult(db, connectionId, result) {
ensureProviderSchema(db);
const now = new Date().toISOString();
const valid = result?.valid === true;
db.prepare(
`UPDATE provider_connections SET
test_status = @testStatus,
last_error = @lastError,
last_error_at = @lastErrorAt,
last_error_type = @lastErrorType,
last_error_source = @lastErrorSource,
error_code = @errorCode,
last_tested = @lastTested,
updated_at = @updatedAt
WHERE id = @id`
).run({
id: connectionId,
testStatus: valid ? "active" : "error",
lastError: valid ? null : result?.error || "Provider test failed",
lastErrorAt: valid ? null : now,
lastErrorType: valid ? null : "connection_test_failed",
lastErrorSource: valid ? null : "upstream",
errorCode: valid ? null : result?.statusCode || null,
lastTested: now,
updatedAt: now,
});
return {
...result,
testedAt: now,
};
}