313 lines
9.4 KiB
JavaScript
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,
|
|
};
|
|
}
|