mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-08 05:28:10 +02:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eafa067b9 | |||
| 5e5e172d39 | |||
| bc7a883f6c | |||
| 12056aebc6 | |||
| 297cb9c8f2 | |||
| d699455b38 | |||
| 9e7b4afaec | |||
| 07f628b928 | |||
| 184e1425a4 | |||
| 22f2990f56 | |||
| 6c28c5f383 | |||
| e9f9cf54f4 | |||
| 751d1b083d | |||
| a4d77a4fd3 | |||
| f72252552f | |||
| c5d68675c9 | |||
| 36b8ef5fba | |||
| 6cca5e0472 | |||
| b4d2974e19 | |||
| fb896d6c0f | |||
| 6564bfe8ae | |||
| 75ce7e91d7 | |||
| 656eea5bb1 | |||
| aba27a7bbf | |||
| 3d7e322bf9 | |||
| 1f3fff4a9d | |||
| 39e06f2b6d | |||
| b775c5b674 | |||
| 796bf37141 | |||
| c5b0e1e876 | |||
| 2e8bd7a8c7 | |||
| 790daba796 | |||
| bcf6b86b84 | |||
| 2e9bd50172 | |||
| c673dce484 | |||
| e1dd8965dc | |||
| 346a0bdc98 | |||
| 8c1169738e | |||
| bc80f91a45 | |||
| 3d062389e9 | |||
| 7afddb5eb5 | |||
| 425a99e5ee | |||
| a5e9339af9 | |||
| 83e20c2dfa | |||
| f85d41945a | |||
| 149eb17b27 | |||
| 72d469b19c | |||
| 6526d7f225 | |||
| 36c0edc62e | |||
| aad26b9ae4 | |||
| 80dcdfe251 | |||
| 1696fc37a8 | |||
| 7759d87835 | |||
| c085de1e9e | |||
| e38359c74f | |||
| 2cfd7e867b | |||
| ec5a2b0cbe | |||
| 2bf426bc22 | |||
| ed8545f8a2 | |||
| 9811492a0b | |||
| 973925b35d | |||
| 0b30cfc341 | |||
| a7e44944fb | |||
| 9b25ebd6cd | |||
| 33b775e263 | |||
| b0edc6302e | |||
| 1a644b131d | |||
| f7689b7a5a | |||
| 1073011a2a | |||
| 6fa0009ebf | |||
| 42c0abedb7 | |||
| 56e59a93f3 | |||
| 9d581f3897 | |||
| e5ac6ec7cd | |||
| 694fe98131 | |||
| 730078597e | |||
| 32ac8db803 | |||
| aad2ed2719 | |||
| a790fed297 | |||
| deb2d5ce2a | |||
| 093097c619 | |||
| 2cdeb7c104 | |||
| 4dbb04bfb8 | |||
| fa8b921635 | |||
| 33dd10c670 | |||
| ba24e1c4f5 |
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# FROM node:24-slim AS base
|
# FROM node:24.18.1-slim AS base
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ FROM base AS builder
|
|||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# FROM node:24-slim AS runner
|
# FROM node:24.18.1-slim AS runner
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:24.18.1-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommandModule } from "yargs";
|
import { CommandModule } from "yargs";
|
||||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders } from "@server/db";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -132,12 +132,14 @@ export const rotateServerSecret: CommandModule<
|
|||||||
const certs = await db.select().from(certificates);
|
const certs = await db.select().from(certificates);
|
||||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||||
const webhookActions = await db.select().from(alertWebhookActions);
|
const webhookActions = await db.select().from(alertWebhookActions);
|
||||||
|
const providers = await db.select().from(aiProviders);
|
||||||
|
|
||||||
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
console.log(`Found ${certs.length} certificate(s)`);
|
console.log(`Found ${certs.length} certificate(s)`);
|
||||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||||
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
||||||
|
console.log(`Found ${providers.length} AI provider(s)`);
|
||||||
|
|
||||||
// Prepare all decrypted and re-encrypted values
|
// Prepare all decrypted and re-encrypted values
|
||||||
console.log("\nDecrypting and re-encrypting values...");
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
@@ -171,11 +173,18 @@ export const rotateServerSecret: CommandModule<
|
|||||||
encryptedConfig: string;
|
encryptedConfig: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiProviderUpdate = {
|
||||||
|
providerId: number;
|
||||||
|
encryptedApiKey: string | null;
|
||||||
|
encryptedHeaders: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const idpUpdates: IdpUpdate[] = [];
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
const certUpdates: CertUpdate[] = [];
|
const certUpdates: CertUpdate[] = [];
|
||||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||||
|
const aiProviderUpdates: AiProviderUpdate[] = [];
|
||||||
|
|
||||||
// Process idpOidcConfig entries
|
// Process idpOidcConfig entries
|
||||||
for (const idpConfig of idpConfigs) {
|
for (const idpConfig of idpConfigs) {
|
||||||
@@ -306,6 +315,37 @@ export const rotateServerSecret: CommandModule<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process aiProviders entries (apiKey + headers)
|
||||||
|
for (const provider of providers) {
|
||||||
|
try {
|
||||||
|
if (!provider.apiKey && !provider.headers) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedApiKey = provider.apiKey
|
||||||
|
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
|
||||||
|
: null;
|
||||||
|
const encryptedHeaders = provider.headers
|
||||||
|
? encrypt(
|
||||||
|
decrypt(provider.headers, oldSecret),
|
||||||
|
newSecret
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
aiProviderUpdates.push({
|
||||||
|
providerId: provider.providerId,
|
||||||
|
encryptedApiKey,
|
||||||
|
encryptedHeaders
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing AI provider ${provider.providerId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Perform all database updates in a single transaction
|
// Perform all database updates in a single transaction
|
||||||
console.log("\nUpdating database in transaction...");
|
console.log("\nUpdating database in transaction...");
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -376,6 +416,17 @@ export const rotateServerSecret: CommandModule<
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update AI provider entries
|
||||||
|
for (const update of aiProviderUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(aiProviders)
|
||||||
|
.set({
|
||||||
|
apiKey: update.encryptedApiKey,
|
||||||
|
headers: update.encryptedHeaders
|
||||||
|
})
|
||||||
|
.where(eq(aiProviders.providerId, update.providerId));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||||
@@ -383,6 +434,7 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
||||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
||||||
|
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
|
||||||
|
|
||||||
// Update config file with new secret
|
// Update config file with new secret
|
||||||
console.log("\nUpdating config file...");
|
console.log("\nUpdating config file...");
|
||||||
@@ -402,6 +454,7 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(` - Certificates: ${certUpdates.length}`);
|
console.log(` - Certificates: ${certUpdates.length}`);
|
||||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||||
|
console.log(` - AI providers: ${aiProviderUpdates.length}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
);
|
);
|
||||||
|
|||||||
+7559
File diff suppressed because it is too large
Load Diff
@@ -1424,6 +1424,16 @@
|
|||||||
"actionDeleteSite": "Delete Site",
|
"actionDeleteSite": "Delete Site",
|
||||||
"actionGetSite": "Get Site",
|
"actionGetSite": "Get Site",
|
||||||
"actionListSites": "List Sites",
|
"actionListSites": "List Sites",
|
||||||
|
"actionCreateAiProvider": "Create AI Provider",
|
||||||
|
"actionDeleteAiProvider": "Delete AI Provider",
|
||||||
|
"actionGetAiProvider": "Get AI Provider",
|
||||||
|
"actionListAiProviders": "List AI Providers",
|
||||||
|
"actionUpdateAiProvider": "Update AI Provider",
|
||||||
|
"actionCreateAiModel": "Create AI Model",
|
||||||
|
"actionDeleteAiModel": "Delete AI Model",
|
||||||
|
"actionGetAiModel": "Get AI Model",
|
||||||
|
"actionListAiModels": "List AI Models",
|
||||||
|
"actionUpdateAiModel": "Update AI Model",
|
||||||
"actionApplyBlueprint": "Apply Blueprint",
|
"actionApplyBlueprint": "Apply Blueprint",
|
||||||
"actionListBlueprints": "List Blueprints",
|
"actionListBlueprints": "List Blueprints",
|
||||||
"actionGetBlueprint": "Get Blueprint",
|
"actionGetBlueprint": "Get Blueprint",
|
||||||
@@ -1622,6 +1632,171 @@
|
|||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Shareable Links",
|
"sidebarShareableLinks": "Shareable Links",
|
||||||
|
"sidebarAiGateway": "AI Gateway",
|
||||||
|
"sidebarAiProviders": "Providers",
|
||||||
|
"commandAiProviders": "AI Providers",
|
||||||
|
"aiProvidersTitle": "AI Providers",
|
||||||
|
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
|
||||||
|
"aiProvidersAdd": "Add Provider",
|
||||||
|
"aiProvidersSearch": "Search providers...",
|
||||||
|
"aiProvidersEmpty": "No AI providers yet",
|
||||||
|
"aiProviderCreate": "Create AI Provider",
|
||||||
|
"aiProviderCreateDescription": "Add a model provider for this organization",
|
||||||
|
"aiProviderSeeAll": "See All Providers",
|
||||||
|
"aiProviderSetting": "Provider Settings for {providerName}",
|
||||||
|
"aiProviderSettingDescription": "Configure this AI provider",
|
||||||
|
"aiProviderGeneral": "General",
|
||||||
|
"aiProviderGeneralDescription": "Basic settings for this provider",
|
||||||
|
"aiProviderConfiguration": "Configuration",
|
||||||
|
"aiProviderConfigurationDescription": "Network routing and authentication for this provider",
|
||||||
|
"aiProviderNetworkSettings": "Network Settings",
|
||||||
|
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
|
||||||
|
"aiProviderAuthSettings": "Authentication",
|
||||||
|
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
|
||||||
|
"aiProviderType": "Provider Type",
|
||||||
|
"aiProviderTypeSearch": "Search providers...",
|
||||||
|
"aiProviderTypeNotFound": "No provider type found",
|
||||||
|
"aiProviderTypeOpenai": "OpenAI",
|
||||||
|
"aiProviderTypeAnthropic": "Anthropic",
|
||||||
|
"aiProviderTypeGoogleGemini": "Google Gemini",
|
||||||
|
"aiProviderTypeVertexAi": "Vertex AI",
|
||||||
|
"aiProviderTypeBedrock": "Amazon Bedrock",
|
||||||
|
"aiProviderTypeMicrosoftFoundry": "Microsoft Foundry",
|
||||||
|
"aiProviderTypeOpenRouter": "OpenRouter",
|
||||||
|
"aiProviderTypeVercelAiGateway": "Vercel AI Gateway",
|
||||||
|
"aiProviderTypeCustom": "Custom",
|
||||||
|
"aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL",
|
||||||
|
"aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL",
|
||||||
|
"aiProviderTypeGoogleGeminiDescription": "Google Gemini generateContent API",
|
||||||
|
"aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required",
|
||||||
|
"aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime",
|
||||||
|
"aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required",
|
||||||
|
"aiProviderTypeOpenRouterDescription": "OpenRouter API",
|
||||||
|
"aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway",
|
||||||
|
"aiProviderTypeCustomDescription": "Bring your own OpenAI-compatible endpoint or route via Pangolin targets",
|
||||||
|
"aiProviderUpstreamUrl": "Upstream URL",
|
||||||
|
"aiProviderUpstreamUrlDescription": "Base URL for the provider API",
|
||||||
|
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
|
||||||
|
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
|
||||||
|
"aiProviderApiKey": "API Key",
|
||||||
|
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
|
||||||
|
"aiProviderCustomHeadersDescription": "Headers sent on every request to this provider. Newline separated: Header-Name: value",
|
||||||
|
"aiProviderApiKeyLastChars": "API Key",
|
||||||
|
"aiProviderAuthType": "Auth Type",
|
||||||
|
"aiProviderAuthTypeSearch": "Search auth types...",
|
||||||
|
"aiProviderAuthTypeNotFound": "No auth type found",
|
||||||
|
"aiProviderAuthTypeBearer": "Bearer",
|
||||||
|
"aiProviderAuthTypeBearerDescription": "Authorization: Bearer key. Used by OpenAI and most providers",
|
||||||
|
"aiProviderAuthTypeXApiKey": "x-api-key",
|
||||||
|
"aiProviderAuthTypeXApiKeyDescription": "x-api-key header. Used by Anthropic",
|
||||||
|
"aiProviderAuthTypeXGoogApiKey": "x-goog-api-key",
|
||||||
|
"aiProviderAuthTypeXGoogApiKeyDescription": "x-goog-api-key header. Used by Google Gemini",
|
||||||
|
"aiProviderAuthTypeHec": "Splunk HEC",
|
||||||
|
"aiProviderAuthTypeHecDescription": "Authorization: Splunk key. Used by Splunk HTTP Event Collector",
|
||||||
|
"aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway",
|
||||||
|
"aiProviderAuthTypeCfAigAuthorizationDescription": "cf-aig-authorization: Bearer key. Used by Cloudflare AI Gateway",
|
||||||
|
"aiProviderAuthTypeNone": "No Auth",
|
||||||
|
"aiProviderAuthTypePassthrough": "Passthrough",
|
||||||
|
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
|
||||||
|
"aiProviderAuthTypePassthroughDescription": "Forward the caller's API key headers to the upstream",
|
||||||
|
"aiProviderAuthTypeNoneDescription": "Do not send authentication headers to the upstream",
|
||||||
|
"aiProviderRoutingMode": "Routing Mode",
|
||||||
|
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites",
|
||||||
|
"aiProviderRoutingModeUrl": "Upstream URL",
|
||||||
|
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
|
||||||
|
"aiProviderRoutingModeTarget": "Site Targets",
|
||||||
|
"aiProviderRoutingModeTargetDescription": "Route through targets on your sites",
|
||||||
|
"aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.",
|
||||||
|
"aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.",
|
||||||
|
"aiProviderSkipTlsVerification": "Skip TLS Verification",
|
||||||
|
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
|
||||||
|
"aiProviderBudget": "Budget",
|
||||||
|
"aiProviderBudgetDescription": "Optional spending or token budget for this provider",
|
||||||
|
"aiProviderBudgetAmount": "Budget Amount",
|
||||||
|
"aiProviderBudgetUnit": "Budget Unit",
|
||||||
|
"aiProviderBudgetUnitUsd": "USD",
|
||||||
|
"aiProviderBudgetUnitTokens": "Tokens",
|
||||||
|
"aiProviderEnabled": "Enabled",
|
||||||
|
"aiProviderEnabledDescription": "Disable to stop using this provider without deleting it",
|
||||||
|
"aiProviderErrorCreate": "Failed to create AI provider",
|
||||||
|
"aiProviderErrorUpdate": "Failed to update AI provider",
|
||||||
|
"aiProviderErrorDelete": "Failed to delete AI provider",
|
||||||
|
"aiProviderErrorLoad": "Failed to load AI provider",
|
||||||
|
"aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL",
|
||||||
|
"aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider",
|
||||||
|
"aiProviderErrorAuthTypeRequired": "Auth type is required",
|
||||||
|
"aiProviderErrorApiKeyRequired": "API key is required",
|
||||||
|
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
|
||||||
|
"aiProviderErrorCapabilitiesRequired": "Select at least one API capability",
|
||||||
|
"aiProviderCapabilities": "API Capabilities",
|
||||||
|
"aiProviderCapabilitiesDescription": "Select which API formats this provider can handle. Known providers start with recommended defaults.",
|
||||||
|
"aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle",
|
||||||
|
"aiProviderCapabilitiesSelect": "Select capabilities",
|
||||||
|
"aiProviderCapabilitiesEmpty": "No capabilities found",
|
||||||
|
"aiProviderCapabilitiesSearch": "Search capabilities...",
|
||||||
|
"aiCapabilityOpenaiChat": "OpenAI Chat Completions",
|
||||||
|
"aiCapabilityOpenaiChatDescription": "Supports /v1/chat/completions",
|
||||||
|
"aiCapabilityOpenaiResponses": "OpenAI Responses",
|
||||||
|
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
||||||
|
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
||||||
|
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
||||||
|
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
||||||
|
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
||||||
|
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
||||||
|
"aiCapabilityBedrockModelInvokeDescription": "Supports Amazon Bedrock InvokeModel",
|
||||||
|
"aiCapabilityGoogleGenerateContent": "Vertex Generate Content",
|
||||||
|
"aiCapabilityGoogleGenerateContentDescription": "Supports Vertex AI Gemini format",
|
||||||
|
"aiCapabilityGoogleRawPredict": "Vertex Raw Predict",
|
||||||
|
"aiCapabilityGoogleRawPredictDescription": "Supports Vertex AI rawPredict for Anthropic models",
|
||||||
|
"aiCapabilityBedrockConverse": "Bedrock Converse",
|
||||||
|
"aiCapabilityBedrockConverseDescription": "Supports Amazon Bedrock Converse API",
|
||||||
|
"aiProviderCreated": "AI provider created",
|
||||||
|
"aiProviderUpdated": "AI provider updated",
|
||||||
|
"aiProviderDeleted": "AI provider deleted",
|
||||||
|
"aiProviderDelete": "Delete Provider",
|
||||||
|
"aiProviderDeleteConfirm": "Delete Provider",
|
||||||
|
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
|
||||||
|
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
||||||
|
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
||||||
|
"aiProviderModels": "Models",
|
||||||
|
"aiProviderModelsDescription": "Define allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Use * and ? as wildcards (for example gpt-4* or claude-?). An empty allow list denies all models.",
|
||||||
|
"aiProviderModelsPlaceholder": "Model name or pattern (e.g. gpt-4*)",
|
||||||
|
"aiProviderModelsAllow": "Allow List",
|
||||||
|
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
|
||||||
|
"aiProviderModelsAllowPlaceholder": "Allowed model or pattern (e.g. gpt-4*)",
|
||||||
|
"aiProviderModelsBlock": "Block List",
|
||||||
|
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.",
|
||||||
|
"aiProviderModelsBlockPlaceholder": "Blocked model or pattern (e.g. gpt-4o-mini)",
|
||||||
|
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
|
||||||
|
"aiProviderModelsUpdated": "Models updated",
|
||||||
|
"aiProviderModelsErrorUpdate": "Failed to update models",
|
||||||
|
"aiResourceProviders": "Providers",
|
||||||
|
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
|
||||||
|
"aiResourceProvidersHelp": "Attach providers and choose inherit (use each provider's lists) or select (pick an allow list for this resource). Allow patterns that conflict across attached providers are not allowed.",
|
||||||
|
"aiResourceProvidersSelect": "Select providers",
|
||||||
|
"aiResourceProvidersEmpty": "No AI providers found",
|
||||||
|
"aiResourceProvidersNoneAttached": "No providers attached yet.",
|
||||||
|
"aiResourceProvidersAdd": "Add provider",
|
||||||
|
"aiResourceProvidersRemove": "Remove provider",
|
||||||
|
"aiResourceProviderToggleEnabled": "Enable or disable this provider on the resource",
|
||||||
|
"aiResourceProviderDisabled": "Disabled",
|
||||||
|
"aiResourceProvidersUpdated": "Providers updated",
|
||||||
|
"aiResourceProvidersErrorUpdate": "Failed to update providers",
|
||||||
|
"aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.",
|
||||||
|
"aiResourceProviderMode": "Access mode",
|
||||||
|
"aiResourceProviderModeInherit": "Inherit",
|
||||||
|
"aiResourceProviderModeSelect": "Select",
|
||||||
|
"aiResourceProviderModeSelectSummary": "Select · {count} models",
|
||||||
|
"aiResourceProviderModeInheritHelp": "Use this provider's allow and block lists as configured on the provider.",
|
||||||
|
"aiResourceProviderModeSelectHelp": "Choose a subset of this provider's allow-list models for this resource.",
|
||||||
|
"aiResourceProviderAllowModels": "Allow list",
|
||||||
|
"aiResourceProviderAllowModelsSelect": "Select models",
|
||||||
|
"aiResourceProviderAllowModelsSearch": "Search models...",
|
||||||
|
"aiResourceProviderAllowModelsEmpty": "No models found",
|
||||||
|
"aiResourceProviderAllowModelsHelp": "Only models from this provider's allow list can be selected.",
|
||||||
|
"aiResourceAliasRequired": "Alias is required for inference resources",
|
||||||
|
"aiResourceDomainConfiguration": "Domain configuration",
|
||||||
|
"aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this inference resource.",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
@@ -2246,6 +2421,7 @@
|
|||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshSettings": "SSH Settings",
|
"sshSettings": "SSH Settings",
|
||||||
|
"inferenceSettings": "Inference Settings",
|
||||||
"sshAccess": "SSH Access",
|
"sshAccess": "SSH Access",
|
||||||
"rdpSettings": "RDP Settings",
|
"rdpSettings": "RDP Settings",
|
||||||
"vncSettings": "VNC Settings",
|
"vncSettings": "VNC Settings",
|
||||||
@@ -2401,6 +2577,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeInference": "Inference",
|
||||||
"editInternalResourceDialogModeSsh": "SSH",
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Scheme",
|
"editInternalResourceDialogScheme": "Scheme",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||||
@@ -2460,6 +2637,7 @@
|
|||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"createInternalResourceDialogModeSsh": "SSH",
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
|
"createInternalResourceDialogModeInference": "Inference",
|
||||||
"scheme": "Scheme",
|
"scheme": "Scheme",
|
||||||
"createInternalResourceDialogScheme": "Scheme",
|
"createInternalResourceDialogScheme": "Scheme",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||||
|
|||||||
Generated
+7
@@ -65,6 +65,7 @@
|
|||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.5.2",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
|
"gpt-tokenizer": "^3.4.0",
|
||||||
"helmet": "8.2.0",
|
"helmet": "8.2.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
@@ -12760,6 +12761,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gpt-tokenizer": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
|||||||
+2
-1
@@ -33,9 +33,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
|
"@aws-sdk/client-s3": "3.1056.0",
|
||||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||||
"@aws-sdk/client-s3": "3.1056.0",
|
|
||||||
"@headlessui/react": "2.2.10",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.4.0",
|
"@hookform/resolvers": "5.4.0",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.5.2",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
|
"gpt-tokenizer": "^3.4.0",
|
||||||
"helmet": "8.2.0",
|
"helmet": "8.2.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import cors from "cors";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import {
|
||||||
|
errorHandlerMiddleware,
|
||||||
|
notFoundMiddleware
|
||||||
|
} from "@server/middlewares";
|
||||||
|
import { createAiGatewayRouter } from "@server/routers/aiGateway";
|
||||||
|
|
||||||
|
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
|
||||||
|
|
||||||
|
export function createAiGatewayServer() {
|
||||||
|
const aiGatewayServer = express();
|
||||||
|
|
||||||
|
const trustProxy = config.getRawConfig().server.trust_proxy;
|
||||||
|
if (trustProxy) {
|
||||||
|
aiGatewayServer.set("trust proxy", trustProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
aiGatewayServer.use(helmet());
|
||||||
|
aiGatewayServer.use(cors());
|
||||||
|
aiGatewayServer.use(express.json());
|
||||||
|
|
||||||
|
aiGatewayServer.use(createAiGatewayRouter());
|
||||||
|
|
||||||
|
aiGatewayServer.use(notFoundMiddleware);
|
||||||
|
aiGatewayServer.use(errorHandlerMiddleware);
|
||||||
|
|
||||||
|
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
|
||||||
|
if (err) throw err;
|
||||||
|
logger.info(
|
||||||
|
`AI gateway server is running on http://localhost:${aiGatewayPort}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return aiGatewayServer;
|
||||||
|
}
|
||||||
+13
-1
@@ -50,6 +50,8 @@ export enum ActionsEnum {
|
|||||||
setResourceUsers = "setResourceUsers",
|
setResourceUsers = "setResourceUsers",
|
||||||
setResourceRoles = "setResourceRoles",
|
setResourceRoles = "setResourceRoles",
|
||||||
listResourceUsers = "listResourceUsers",
|
listResourceUsers = "listResourceUsers",
|
||||||
|
listResourceAiModels = "listResourceAiModels",
|
||||||
|
setResourceAiModels = "setResourceAiModels",
|
||||||
// removeRoleSite = "removeRoleSite",
|
// removeRoleSite = "removeRoleSite",
|
||||||
// addRoleAction = "addRoleAction",
|
// addRoleAction = "addRoleAction",
|
||||||
// removeRoleAction = "removeRoleAction",
|
// removeRoleAction = "removeRoleAction",
|
||||||
@@ -182,7 +184,17 @@ export enum ActionsEnum {
|
|||||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||||
setResourcePolicyRules = "setResourcePolicyRules",
|
setResourcePolicyRules = "setResourcePolicyRules",
|
||||||
createOrgWideLauncherView = "createOrgWideLauncherView"
|
createOrgWideLauncherView = "createOrgWideLauncherView",
|
||||||
|
createAiProvider = "createAiProvider",
|
||||||
|
deleteAiProvider = "deleteAiProvider",
|
||||||
|
getAiProvider = "getAiProvider",
|
||||||
|
listAiProviders = "listAiProviders",
|
||||||
|
updateAiProvider = "updateAiProvider",
|
||||||
|
createAiModel = "createAiModel",
|
||||||
|
deleteAiModel = "deleteAiModel",
|
||||||
|
getAiModel = "getAiModel",
|
||||||
|
listAiModels = "listAiModels",
|
||||||
|
updateAiModel = "updateAiModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InferSelectModel, sql } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
boolean,
|
boolean,
|
||||||
|
check,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -99,6 +100,7 @@ export const sites = pgTable(
|
|||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
pubKey: varchar("pubKey"),
|
pubKey: varchar("pubKey"),
|
||||||
subnet: varchar("subnet"),
|
subnet: varchar("subnet"),
|
||||||
|
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR
|
||||||
megabytesIn: real("bytesIn").default(0),
|
megabytesIn: real("bytesIn").default(0),
|
||||||
megabytesOut: real("bytesOut").default(0),
|
megabytesOut: real("bytesOut").default(0),
|
||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
@@ -194,7 +196,12 @@ export const resources = pgTable(
|
|||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: boolean("wildcard").notNull().default(false),
|
wildcard: boolean("wildcard").notNull().default(false),
|
||||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
mode: text("mode")
|
||||||
|
.default("http")
|
||||||
|
.$type<
|
||||||
|
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
|
||||||
|
>()
|
||||||
|
.notNull(),
|
||||||
pamMode: varchar("pamMode", { length: 32 })
|
pamMode: varchar("pamMode", { length: 32 })
|
||||||
.$type<"passthrough" | "push">()
|
.$type<"passthrough" | "push">()
|
||||||
.default("passthrough"),
|
.default("passthrough"),
|
||||||
@@ -215,6 +222,41 @@ export const resources = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const resourceAiProviders = pgTable(
|
||||||
|
"resourceAiProviders",
|
||||||
|
{
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
providerId: integer("providerId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
|
accessMode: varchar("accessMode")
|
||||||
|
.$type<"inherit" | "select">()
|
||||||
|
.notNull()
|
||||||
|
.default("inherit"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true)
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resourceAiModels = pgTable(
|
||||||
|
"resourceAiModels",
|
||||||
|
{
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
modelId: integer("modelId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: varchar("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||||
|
);
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
export const labels = pgTable("labels", {
|
||||||
labelId: serial("labelId").primaryKey(),
|
labelId: serial("labelId").primaryKey(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
@@ -317,11 +359,18 @@ export const targets = pgTable(
|
|||||||
"targets",
|
"targets",
|
||||||
{
|
{
|
||||||
targetId: serial("targetId").primaryKey(),
|
targetId: serial("targetId").primaryKey(),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId").references(
|
||||||
.references(() => resources.resourceId, {
|
() => resources.resourceId,
|
||||||
|
{
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
}
|
||||||
.notNull(),
|
),
|
||||||
|
providerId: integer("providerId").references(
|
||||||
|
() => aiProviders.providerId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade"
|
||||||
|
}
|
||||||
|
),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId")
|
||||||
.references(() => sites.siteId, {
|
.references(() => sites.siteId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
@@ -345,6 +394,7 @@ export const targets = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
|
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
|
||||||
|
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
|
||||||
index("idx_targets_site_enabled_priority_target_resource")
|
index("idx_targets_site_enabled_priority_target_resource")
|
||||||
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
|
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
|
||||||
.where(sql`${t.enabled} = true`)
|
.where(sql`${t.enabled} = true`)
|
||||||
@@ -424,11 +474,14 @@ export const siteResources = pgTable(
|
|||||||
onDelete: "restrict"
|
onDelete: "restrict"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
niceId: varchar("niceId").notNull(),
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
ssl: boolean("ssl").notNull().default(false),
|
ssl: boolean("ssl").notNull().default(false),
|
||||||
mode: varchar("mode")
|
mode: varchar("mode")
|
||||||
.$type<"host" | "cidr" | "http" | "ssh">()
|
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||||
.notNull(), // "host" | "cidr" | "http"
|
.notNull(), // "host" | "cidr" | "http"
|
||||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
@@ -463,6 +516,45 @@ export const siteResources = pgTable(
|
|||||||
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
|
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const siteResourceAiProviders = pgTable(
|
||||||
|
"siteResourceAiProviders",
|
||||||
|
{
|
||||||
|
siteResourceId: integer("siteResourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => siteResources.siteResourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
providerId: integer("providerId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
|
accessMode: varchar("accessMode")
|
||||||
|
.$type<"inherit" | "select">()
|
||||||
|
.notNull()
|
||||||
|
.default("inherit"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true)
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const siteResourceAiModels = pgTable(
|
||||||
|
"siteResourceAiModels",
|
||||||
|
{
|
||||||
|
siteResourceId: integer("siteResourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => siteResources.siteResourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
modelId: integer("modelId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: varchar("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
||||||
|
);
|
||||||
|
|
||||||
export const networks = pgTable(
|
export const networks = pgTable(
|
||||||
"networks",
|
"networks",
|
||||||
{
|
{
|
||||||
@@ -1181,7 +1273,7 @@ export const clients = pgTable(
|
|||||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
pubKey: varchar("pubKey"),
|
pubKey: varchar("pubKey"),
|
||||||
subnet: varchar("subnet").notNull(),
|
exitNodeSubnet: varchar("exitNodeSubnet").notNull(), // INCLUDES THE CIDR
|
||||||
megabytesIn: real("bytesIn"),
|
megabytesIn: real("bytesIn"),
|
||||||
megabytesOut: real("bytesOut"),
|
megabytesOut: real("bytesOut"),
|
||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
@@ -1540,6 +1632,140 @@ export const statusHistory = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aiProviders = pgTable("aiProviders", {
|
||||||
|
providerId: serial("providerId").primaryKey(),
|
||||||
|
orgId: varchar("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name").notNull(),
|
||||||
|
type: varchar("type")
|
||||||
|
.$type<
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "googleGemini"
|
||||||
|
| "vertexAi"
|
||||||
|
| "bedrock"
|
||||||
|
| "microsoftFoundry"
|
||||||
|
| "openRouter"
|
||||||
|
| "vercelAiGateway"
|
||||||
|
| "custom"
|
||||||
|
>()
|
||||||
|
.notNull(),
|
||||||
|
upstreamUrl: text("upstreamUrl"),
|
||||||
|
apiKey: text("apiKey"),
|
||||||
|
apiKeyLastChars: varchar("apiKeyLastChars"),
|
||||||
|
authType: varchar("authType")
|
||||||
|
.$type<
|
||||||
|
| "bearer"
|
||||||
|
| "x-api-key"
|
||||||
|
| "x-goog-api-key"
|
||||||
|
| "hec"
|
||||||
|
| "cf-aig-authorization"
|
||||||
|
| "none"
|
||||||
|
| "passthrough"
|
||||||
|
>()
|
||||||
|
.notNull(),
|
||||||
|
routingMode: varchar("routingMode")
|
||||||
|
.$type<"url" | "target">()
|
||||||
|
.notNull()
|
||||||
|
.default("url"),
|
||||||
|
capabilities: text("capabilities").notNull().default("[]"),
|
||||||
|
headers: text("headers"), // JSON array of { name, value }
|
||||||
|
skipTlsVerification: boolean("skipTlsVerification")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
|
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiModels = pgTable(
|
||||||
|
"aiModels",
|
||||||
|
{
|
||||||
|
modelId: serial("modelId").primaryKey(),
|
||||||
|
providerId: integer("providerId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
|
modelKey: varchar("modelKey").notNull(),
|
||||||
|
name: varchar("name").notNull(),
|
||||||
|
listType: varchar("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
|
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiBudgets = pgTable(
|
||||||
|
"aiBudgets",
|
||||||
|
{
|
||||||
|
budgetId: serial("budgetId").primaryKey(),
|
||||||
|
orgId: varchar("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
providerId: integer("providerId").references(
|
||||||
|
() => aiProviders.providerId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
modelId: integer("modelId").references(() => aiModels.modelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
resourceId: integer("resourceId").references(
|
||||||
|
() => resources.resourceId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
siteResourceId: integer("siteResourceId").references(
|
||||||
|
() => siteResources.siteResourceId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
amount: real("amount").notNull(),
|
||||||
|
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
|
||||||
|
period: varchar("period")
|
||||||
|
.$type<"monthly">()
|
||||||
|
.notNull()
|
||||||
|
.default("monthly"),
|
||||||
|
enforcement: varchar("enforcement")
|
||||||
|
.$type<"hard" | "soft">()
|
||||||
|
.notNull()
|
||||||
|
.default("hard"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
|
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
check(
|
||||||
|
"ai_budget_one_scope",
|
||||||
|
sql`(
|
||||||
|
(CASE WHEN ${t.providerId} IS NOT NULL THEN 1 ELSE 0 END) +
|
||||||
|
(CASE WHEN ${t.modelId} IS NOT NULL THEN 1 ELSE 0 END) +
|
||||||
|
(CASE WHEN ${t.resourceId} IS NOT NULL THEN 1 ELSE 0 END) +
|
||||||
|
(CASE WHEN ${t.siteResourceId} IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
|
) = 1`
|
||||||
|
),
|
||||||
|
unique("ai_budget_provider_uniq").on(t.providerId),
|
||||||
|
unique("ai_budget_model_uniq").on(t.modelId),
|
||||||
|
unique("ai_budget_resource_uniq").on(t.resourceId),
|
||||||
|
unique("ai_budget_site_resource_uniq").on(t.siteResourceId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiBudgetPeriods = pgTable(
|
||||||
|
"aiBudgetPeriods",
|
||||||
|
{
|
||||||
|
periodId: serial("periodId").primaryKey(),
|
||||||
|
budgetId: integer("budgetId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
|
||||||
|
periodStart: bigint("periodStart", { mode: "number" }).notNull(),
|
||||||
|
periodEnd: bigint("periodEnd", { mode: "number" }).notNull(),
|
||||||
|
usedAmount: real("usedAmount").notNull().default(0)
|
||||||
|
},
|
||||||
|
(t) => [unique("ai_budget_period_start_uniq").on(t.budgetId, t.periodStart)]
|
||||||
|
);
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1624,3 +1850,13 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
|||||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
|
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
|
||||||
|
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||||
|
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||||
|
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||||
|
export type AiBudgetPeriod = InferSelectModel<typeof aiBudgetPeriods>;
|
||||||
|
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||||
|
export type SiteResourceAiProvider = InferSelectModel<
|
||||||
|
typeof siteResourceAiProviders
|
||||||
|
>;
|
||||||
|
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||||
|
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||||
|
|||||||
@@ -6,13 +6,48 @@ import fs from "fs";
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
|
||||||
|
// Temporary diagnostic trace for the random better-sqlite3 native aborts
|
||||||
|
// (Statement::~Statement -> RemoveEnvironmentCleanupHook assertion). That
|
||||||
|
// abort is a hard SIGABRT from native code, so it bypasses uncaughtException/
|
||||||
|
// unhandledRejection and can outrun winston's async file transport. This
|
||||||
|
// writes every statement text synchronously (fsync'd via appendFileSync) so
|
||||||
|
// the statements immediately preceding a crash survive it. better-sqlite3's
|
||||||
|
// `verbose` hook fires for BEGIN/SAVEPOINT/RELEASE/COMMIT/ROLLBACK too, since
|
||||||
|
// those are just prepared statements under the hood - so this also lets us
|
||||||
|
// see if two "transactions" ever overlap on the shared connection.
|
||||||
|
// Enable with SQL_TRACE=true; remove once the crash is root-caused.
|
||||||
|
function sqlTraceVerbose():
|
||||||
|
| ((message: unknown, ...args: unknown[]) => void)
|
||||||
|
| undefined {
|
||||||
|
if (process.env.SQL_TRACE !== "true") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const traceLogDir = path.join(APP_PATH, "logs");
|
||||||
|
if (!existsSync(traceLogDir)) {
|
||||||
|
mkdirSync(traceLogDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const traceLogPath = path.join(traceLogDir, "sql-trace.log");
|
||||||
|
let seq = 0;
|
||||||
|
return (message: unknown) => {
|
||||||
|
seq += 1;
|
||||||
|
const line = `${new Date().toISOString()} pid=${process.pid} #${seq} ${String(message).replace(/\s+/g, " ").trim()}\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(traceLogPath, line);
|
||||||
|
} catch {
|
||||||
|
// best-effort diagnostic logging only
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
export const exists = checkFileExists(location);
|
export const exists = checkFileExists(location);
|
||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const verbose =
|
||||||
|
process.env.QUERY_LOGGING == "true" ? sqlTraceVerbose() : undefined;
|
||||||
|
const sqlite = new Database(location, { verbose });
|
||||||
|
|
||||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
check,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
real,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
unique
|
unique
|
||||||
@@ -107,7 +109,7 @@ export const sites = sqliteTable("sites", {
|
|||||||
}),
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
pubKey: text("pubKey"),
|
pubKey: text("pubKey"),
|
||||||
subnet: text("subnet"),
|
exitNodeSubnet: text("exitNodeSubnet"),
|
||||||
megabytesIn: integer("bytesIn").default(0),
|
megabytesIn: integer("bytesIn").default(0),
|
||||||
megabytesOut: integer("bytesOut").default(0),
|
megabytesOut: integer("bytesOut").default(0),
|
||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
@@ -203,7 +205,10 @@ export const resources = sqliteTable("resources", {
|
|||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
mode: text("mode")
|
||||||
|
.default("http")
|
||||||
|
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
|
||||||
|
.notNull(), // rdp, ssh, http, vnc, inference
|
||||||
pamMode: text("pamMode")
|
pamMode: text("pamMode")
|
||||||
.$type<"passthrough" | "push">()
|
.$type<"passthrough" | "push">()
|
||||||
.default("passthrough"),
|
.default("passthrough"),
|
||||||
@@ -214,6 +219,41 @@ export const resources = sqliteTable("resources", {
|
|||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourceAiProviders = sqliteTable(
|
||||||
|
"resourceAiProviders",
|
||||||
|
{
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
providerId: integer("providerId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
|
accessMode: text("accessMode")
|
||||||
|
.$type<"inherit" | "select">()
|
||||||
|
.notNull()
|
||||||
|
.default("inherit"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resourceAiModels = sqliteTable(
|
||||||
|
"resourceAiModels",
|
||||||
|
{
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
modelId: integer("modelId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: text("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||||
|
);
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable("labels", {
|
||||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
@@ -322,11 +362,12 @@ export const clientLabels = sqliteTable(
|
|||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId").references(() => resources.resourceId, {
|
||||||
.references(() => resources.resourceId, {
|
onDelete: "cascade"
|
||||||
onDelete: "cascade"
|
}),
|
||||||
})
|
providerId: integer("providerId").references(() => aiProviders.providerId, {
|
||||||
.notNull(),
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId")
|
||||||
.references(() => sites.siteId, {
|
.references(() => sites.siteId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
@@ -422,10 +463,17 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
() => networks.networkId,
|
() => networks.networkId,
|
||||||
{ onDelete: "restrict" }
|
{ onDelete: "restrict" }
|
||||||
),
|
),
|
||||||
|
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
|
||||||
|
mode: "boolean"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
mode: text("mode")
|
||||||
|
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||||
|
.notNull(), // "host" | "cidr" | "http"
|
||||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
@@ -453,6 +501,45 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const siteResourceAiProviders = sqliteTable(
|
||||||
|
"siteResourceAiProviders",
|
||||||
|
{
|
||||||
|
siteResourceId: integer("siteResourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => siteResources.siteResourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
providerId: integer("providerId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
|
accessMode: text("accessMode")
|
||||||
|
.$type<"inherit" | "select">()
|
||||||
|
.notNull()
|
||||||
|
.default("inherit"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const siteResourceAiModels = sqliteTable(
|
||||||
|
"siteResourceAiModels",
|
||||||
|
{
|
||||||
|
siteResourceId: integer("siteResourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => siteResources.siteResourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
modelId: integer("modelId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||||
|
listType: text("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow")
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
||||||
|
);
|
||||||
|
|
||||||
export const networks = sqliteTable("networks", {
|
export const networks = sqliteTable("networks", {
|
||||||
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
||||||
niceId: text("niceId"),
|
niceId: text("niceId"),
|
||||||
@@ -599,6 +686,7 @@ export const clients = sqliteTable("clients", {
|
|||||||
pubKey: text("pubKey"),
|
pubKey: text("pubKey"),
|
||||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||||
subnet: text("subnet").notNull(),
|
subnet: text("subnet").notNull(),
|
||||||
|
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||||
megabytesIn: integer("bytesIn"),
|
megabytesIn: integer("bytesIn"),
|
||||||
megabytesOut: integer("bytesOut"),
|
megabytesOut: integer("bytesOut"),
|
||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
@@ -1526,6 +1614,132 @@ export const statusHistory = sqliteTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aiProviders = sqliteTable("aiProviders", {
|
||||||
|
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
type: text("type")
|
||||||
|
.$type<
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "googleGemini"
|
||||||
|
| "vertexAi"
|
||||||
|
| "bedrock"
|
||||||
|
| "microsoftFoundry"
|
||||||
|
| "openRouter"
|
||||||
|
| "vercelAiGateway"
|
||||||
|
| "custom"
|
||||||
|
>()
|
||||||
|
.notNull(),
|
||||||
|
upstreamUrl: text("upstreamUrl"),
|
||||||
|
apiKey: text("apiKey"),
|
||||||
|
apiKeyLastChars: text("apiKeyLastChars"),
|
||||||
|
authType: text("authType")
|
||||||
|
.$type<
|
||||||
|
| "bearer"
|
||||||
|
| "x-api-key"
|
||||||
|
| "x-goog-api-key"
|
||||||
|
| "hec"
|
||||||
|
| "cf-aig-authorization"
|
||||||
|
| "none"
|
||||||
|
| "passthrough"
|
||||||
|
>()
|
||||||
|
.notNull(),
|
||||||
|
routingMode: text("routingMode")
|
||||||
|
.$type<"url" | "target">()
|
||||||
|
.notNull()
|
||||||
|
.default("url"),
|
||||||
|
capabilities: text("capabilities").notNull().default("[]"),
|
||||||
|
headers: text("headers"), // JSON array of { name, value }
|
||||||
|
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: integer("createdAt").notNull(),
|
||||||
|
updatedAt: integer("updatedAt").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiModels = sqliteTable(
|
||||||
|
"aiModels",
|
||||||
|
{
|
||||||
|
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
|
||||||
|
providerId: integer("providerId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
|
modelKey: text("modelKey").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
listType: text("listType")
|
||||||
|
.$type<"allow" | "block">()
|
||||||
|
.notNull()
|
||||||
|
.default("allow"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
createdAt: integer("createdAt").notNull(),
|
||||||
|
updatedAt: integer("updatedAt").notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiBudgets = sqliteTable(
|
||||||
|
"aiBudgets",
|
||||||
|
{
|
||||||
|
budgetId: integer("budgetId").primaryKey({ autoIncrement: true }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
providerId: integer("providerId").references(
|
||||||
|
() => aiProviders.providerId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
modelId: integer("modelId").references(() => aiModels.modelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
resourceId: integer("resourceId").references(
|
||||||
|
() => resources.resourceId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
siteResourceId: integer("siteResourceId").references(
|
||||||
|
() => siteResources.siteResourceId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
amount: real("amount").notNull(),
|
||||||
|
unit: text("unit").$type<"usd" | "tokens">().notNull(),
|
||||||
|
period: text("period").$type<"monthly">().notNull().default("monthly"),
|
||||||
|
enforcement: text("enforcement")
|
||||||
|
.$type<"hard" | "soft">()
|
||||||
|
.notNull()
|
||||||
|
.default("hard"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
createdAt: integer("createdAt").notNull(),
|
||||||
|
updatedAt: integer("updatedAt").notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique("ai_budget_provider_uniq").on(t.providerId),
|
||||||
|
unique("ai_budget_model_uniq").on(t.modelId),
|
||||||
|
unique("ai_budget_resource_uniq").on(t.resourceId),
|
||||||
|
unique("ai_budget_site_resource_uniq").on(t.siteResourceId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiBudgetPeriods = sqliteTable(
|
||||||
|
"aiBudgetPeriods",
|
||||||
|
{
|
||||||
|
periodId: integer("periodId").primaryKey({ autoIncrement: true }),
|
||||||
|
budgetId: integer("budgetId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
|
||||||
|
periodStart: integer("periodStart").notNull(),
|
||||||
|
periodEnd: integer("periodEnd").notNull(),
|
||||||
|
usedAmount: real("usedAmount").notNull().default(0)
|
||||||
|
},
|
||||||
|
(t) => [unique("ai_budget_period_start_uniq").on(t.budgetId, t.periodStart)]
|
||||||
|
);
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1608,3 +1822,13 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
|
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||||
|
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||||
|
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||||
|
export type AiBudgetPeriod = InferSelectModel<typeof aiBudgetPeriods>;
|
||||||
|
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||||
|
export type SiteResourceAiProvider = InferSelectModel<
|
||||||
|
typeof siteResourceAiProviders
|
||||||
|
>;
|
||||||
|
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||||
|
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { runSetupFunctions } from "./setup";
|
|||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
import { createNextServer } from "./nextServer";
|
import { createNextServer } from "./nextServer";
|
||||||
import { createInternalServer } from "./internalServer";
|
import { createInternalServer } from "./internalServer";
|
||||||
|
import { createAiGatewayServer } from "./aiGatewayServer";
|
||||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||||
import {
|
import {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiKeyOrg,
|
ApiKeyOrg,
|
||||||
|
AiModel,
|
||||||
|
AiProvider,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
Session,
|
Session,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
@@ -47,6 +50,7 @@ async function startServers() {
|
|||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
const internalServer = createInternalServer();
|
const internalServer = createInternalServer();
|
||||||
|
const aiGatewayServer = createAiGatewayServer();
|
||||||
|
|
||||||
const nextServer = await createNextServer();
|
const nextServer = await createNextServer();
|
||||||
if (config.getRawConfig().traefik.file_mode) {
|
if (config.getRawConfig().traefik.file_mode) {
|
||||||
@@ -65,6 +69,7 @@ async function startServers() {
|
|||||||
apiServer,
|
apiServer,
|
||||||
nextServer,
|
nextServer,
|
||||||
internalServer,
|
internalServer,
|
||||||
|
aiGatewayServer,
|
||||||
integrationServer
|
integrationServer
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,8 @@ declare global {
|
|||||||
userOrgIds?: string[];
|
userOrgIds?: string[];
|
||||||
remoteExitNode?: RemoteExitNode;
|
remoteExitNode?: RemoteExitNode;
|
||||||
siteResource?: SiteResource;
|
siteResource?: SiteResource;
|
||||||
|
aiProvider?: AiProvider;
|
||||||
|
aiModel?: AiModel;
|
||||||
orgPolicyAllowed?: boolean;
|
orgPolicyAllowed?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
export const AI_CAPABILITIES = [
|
||||||
|
"openai_chat",
|
||||||
|
"openai_responses",
|
||||||
|
"anthropic_messages",
|
||||||
|
"gemini_generate_content",
|
||||||
|
"bedrock_model_invoke",
|
||||||
|
"google_generate_content",
|
||||||
|
"google_raw_predict",
|
||||||
|
"bedrock_converse"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AiCapability = (typeof AI_CAPABILITIES)[number];
|
||||||
|
|
||||||
|
export type AiCapabilityRoute = {
|
||||||
|
method: "POST";
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCapabilityDefinition = {
|
||||||
|
id: AiCapability;
|
||||||
|
routes: AiCapabilityRoute[];
|
||||||
|
extractModel: (req: Request) => string | undefined;
|
||||||
|
resolveUpstreamUrl: (
|
||||||
|
baseUrl: string,
|
||||||
|
req: Request,
|
||||||
|
model: string
|
||||||
|
) => string;
|
||||||
|
isStreaming: (req: Request, contentType: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bodyModel(req: Request): string | undefined {
|
||||||
|
return typeof req.body?.model === "string" ? req.body.model : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramModel(req: Request): string | undefined {
|
||||||
|
const model = req.params?.model;
|
||||||
|
return typeof model === "string" && model.length > 0 ? model : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinUpstreamUrl(baseUrl: string, path: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
let suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
|
||||||
|
let basePathname = "/";
|
||||||
|
try {
|
||||||
|
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
|
||||||
|
} catch {
|
||||||
|
// Fall through with "/" non-absolute bases are not expected in
|
||||||
|
// production, but keep joining usable for malformed input.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basePathname !== "/") {
|
||||||
|
const baseSegs = basePathname.split("/").filter(Boolean);
|
||||||
|
const pathSegs = suffix.split("/").filter(Boolean);
|
||||||
|
const max = Math.min(baseSegs.length, pathSegs.length);
|
||||||
|
let overlap = 0;
|
||||||
|
for (let n = max; n >= 1; n--) {
|
||||||
|
const baseSuffix = baseSegs.slice(-n);
|
||||||
|
const pathPrefix = pathSegs.slice(0, n);
|
||||||
|
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
|
||||||
|
overlap = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlap > 0) {
|
||||||
|
const remaining = pathSegs.slice(overlap);
|
||||||
|
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix === "/") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromRequest(req: Request): string {
|
||||||
|
const raw = req.originalUrl || req.url || req.path;
|
||||||
|
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyRequestsStream(req: Request): boolean {
|
||||||
|
return req.body?.stream === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeIsSse(contentType: string): boolean {
|
||||||
|
return contentType.includes("text/event-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeIsAmazonEventStream(contentType: string): boolean {
|
||||||
|
return contentType.includes("application/vnd.amazon.eventstream");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathIncludes(req: Request, fragment: string): boolean {
|
||||||
|
return pathFromRequest(req).includes(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBodyOrSseStreaming(req: Request, contentType: string): boolean {
|
||||||
|
return bodyRequestsStream(req) || contentTypeIsSse(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeminiStyleStreaming(req: Request, contentType: string): boolean {
|
||||||
|
return (
|
||||||
|
pathIncludes(req, "streamGenerateContent") ||
|
||||||
|
pathIncludes(req, "alt=sse") ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
||||||
|
{
|
||||||
|
openai_chat: {
|
||||||
|
id: "openai_chat",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/v1/chat/completions" },
|
||||||
|
{ method: "POST", path: "/chat/completions" }
|
||||||
|
],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isBodyOrSseStreaming
|
||||||
|
},
|
||||||
|
openai_responses: {
|
||||||
|
id: "openai_responses",
|
||||||
|
routes: [{ method: "POST", path: "/v1/responses" }],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isBodyOrSseStreaming
|
||||||
|
},
|
||||||
|
anthropic_messages: {
|
||||||
|
id: "anthropic_messages",
|
||||||
|
routes: [{ method: "POST", path: "/v1/messages" }],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isBodyOrSseStreaming
|
||||||
|
},
|
||||||
|
gemini_generate_content: {
|
||||||
|
id: "gemini_generate_content",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1beta/models/:model\\:generateContent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1beta/models/:model\\:streamGenerateContent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isGeminiStyleStreaming
|
||||||
|
},
|
||||||
|
google_generate_content: {
|
||||||
|
id: "google_generate_content",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
// Vertex publisher model generateContent
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isGeminiStyleStreaming
|
||||||
|
},
|
||||||
|
google_raw_predict: {
|
||||||
|
id: "google_raw_predict",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: (req, contentType) =>
|
||||||
|
pathIncludes(req, "streamRawPredict") ||
|
||||||
|
pathIncludes(req, "alt=sse") ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
},
|
||||||
|
bedrock_model_invoke: {
|
||||||
|
id: "bedrock_model_invoke",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/model/:model/invoke" },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/model/:model/invoke-with-response-stream"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: (req, contentType) =>
|
||||||
|
pathIncludes(req, "invoke-with-response-stream") ||
|
||||||
|
contentTypeIsAmazonEventStream(contentType) ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
},
|
||||||
|
bedrock_converse: {
|
||||||
|
id: "bedrock_converse",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/model/:model/converse" },
|
||||||
|
{ method: "POST", path: "/model/:model/converse-stream" }
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: (req, contentType) =>
|
||||||
|
pathIncludes(req, "converse-stream") ||
|
||||||
|
contentTypeIsAmazonEventStream(contentType) ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAiCapability(value: unknown): value is AiCapability {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(AI_CAPABILITIES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCapabilities(raw: unknown): AiCapability[] {
|
||||||
|
if (raw == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: AiCapability[] = [];
|
||||||
|
const seen = new Set<AiCapability>();
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (isAiCapability(item) && !seen.has(item)) {
|
||||||
|
seen.add(item);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCapabilities(capabilities: AiCapability[]): string {
|
||||||
|
return JSON.stringify(capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerHasCapability(
|
||||||
|
capabilities: AiCapability[] | string | null | undefined,
|
||||||
|
capability: AiCapability
|
||||||
|
): boolean {
|
||||||
|
const list =
|
||||||
|
typeof capabilities === "string" || capabilities == null
|
||||||
|
? parseCapabilities(capabilities)
|
||||||
|
: capabilities;
|
||||||
|
return list.includes(capability);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
|
type UpstreamFetchInit = {
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
skipTlsVerification?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
const insecureHttpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
keepAlive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export function aiGatewayUpstreamFetch(
|
||||||
|
url: string,
|
||||||
|
init: UpstreamFetchInit
|
||||||
|
): Promise<Response> {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const isHttps = parsed.protocol === "https:";
|
||||||
|
const lib = isHttps ? https : http;
|
||||||
|
const agent =
|
||||||
|
isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (init.signal?.aborted) {
|
||||||
|
reject(init.signal.reason ?? new Error("Request aborted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = lib.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: init.method,
|
||||||
|
headers: init.headers,
|
||||||
|
agent
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const [key, value] of Object.entries(res.headers)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
headers.append(key, entry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>;
|
||||||
|
resolve(
|
||||||
|
new Response(body, {
|
||||||
|
status: res.statusCode ?? 502,
|
||||||
|
statusText: res.statusMessage,
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
|
||||||
|
if (init.signal) {
|
||||||
|
const onAbort = () => req.destroy(init.signal!.reason);
|
||||||
|
init.signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
req.on("close", () =>
|
||||||
|
init.signal!.removeEventListener("abort", onAbort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (init.body !== undefined) {
|
||||||
|
req.write(init.body);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
aiModels,
|
||||||
|
aiProviders,
|
||||||
|
db,
|
||||||
|
resourceAiModels,
|
||||||
|
resourceAiProviders,
|
||||||
|
siteResourceAiModels,
|
||||||
|
siteResourceAiProviders,
|
||||||
|
type Transaction
|
||||||
|
} from "@server/db";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { modelKeysConflict } from "@server/lib/aiModelKeyMatch";
|
||||||
|
|
||||||
|
type DbOrTrx = Transaction | typeof db;
|
||||||
|
|
||||||
|
export const modelListTypeSchema = z.enum(["allow", "block"]);
|
||||||
|
|
||||||
|
export type ModelListType = z.infer<typeof modelListTypeSchema>;
|
||||||
|
|
||||||
|
export const accessModeSchema = z.enum(["inherit", "select"]);
|
||||||
|
|
||||||
|
export type AccessMode = z.infer<typeof accessModeSchema>;
|
||||||
|
|
||||||
|
export const resourceAiProviderAttachmentSchema = z.strictObject({
|
||||||
|
providerId: z.number().int().positive(),
|
||||||
|
accessMode: accessModeSchema.optional().default("inherit"),
|
||||||
|
enabled: z.boolean().optional().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResourceAiProviderInput = z.infer<
|
||||||
|
typeof resourceAiProviderAttachmentSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ResourceAiProviderAttachment = {
|
||||||
|
providerId: number;
|
||||||
|
accessMode: AccessMode;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceAiModelEntrySchema = z.strictObject({
|
||||||
|
modelId: z.number().int().positive(),
|
||||||
|
listType: modelListTypeSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResourceAiModelEntry = z.infer<typeof resourceAiModelEntrySchema>;
|
||||||
|
|
||||||
|
export type InferenceFieldsError = {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isInferenceFieldsError(
|
||||||
|
value: { error: string } | object
|
||||||
|
): value is InferenceFieldsError {
|
||||||
|
return "error" in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which allow/block patterns apply for an attachment.
|
||||||
|
* inherit → provider lists; select → resource-selected lists (replace).
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveLists(input: {
|
||||||
|
accessMode: AccessMode;
|
||||||
|
providerAllows: string[];
|
||||||
|
providerBlocks: string[];
|
||||||
|
resourceAllows: string[];
|
||||||
|
resourceBlocks: string[];
|
||||||
|
}): { allows: string[]; blocks: string[] } {
|
||||||
|
if (input.accessMode === "select") {
|
||||||
|
return {
|
||||||
|
allows: input.resourceAllows,
|
||||||
|
blocks: input.resourceBlocks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allows: input.providerAllows,
|
||||||
|
blocks: input.providerBlocks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachments(
|
||||||
|
inputs: ResourceAiProviderInput[]
|
||||||
|
): ResourceAiProviderAttachment[] {
|
||||||
|
const byProviderId = new Map<
|
||||||
|
number,
|
||||||
|
{ accessMode: AccessMode; enabled: boolean }
|
||||||
|
>();
|
||||||
|
for (const input of inputs) {
|
||||||
|
byProviderId.set(input.providerId, {
|
||||||
|
accessMode: input.accessMode ?? "inherit",
|
||||||
|
enabled: input.enabled ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...byProviderId.entries()].map(
|
||||||
|
([providerId, { accessMode, enabled }]) => ({
|
||||||
|
providerId,
|
||||||
|
accessMode,
|
||||||
|
enabled
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectiveAllowRow = {
|
||||||
|
providerId: number;
|
||||||
|
modelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure effective allow modelKeys do not conflict across attached providers.
|
||||||
|
* inherit uses provider allows; select uses resource-selected allows (or the
|
||||||
|
* optional override map). Block patterns are ignored for overlap checks.
|
||||||
|
*/
|
||||||
|
export async function assertNoOverlappingModelKeys(
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
options: {
|
||||||
|
trx?: DbOrTrx;
|
||||||
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
|
selectedAllowsByProvider?: Map<number, string[]>;
|
||||||
|
} = {}
|
||||||
|
): Promise<InferenceFieldsError | null> {
|
||||||
|
const trx = options.trx ?? db;
|
||||||
|
|
||||||
|
const activeAttachments = attachments.filter((a) => a.enabled);
|
||||||
|
|
||||||
|
if (activeAttachments.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inheritProviderIds = activeAttachments
|
||||||
|
.filter((a) => a.accessMode === "inherit")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
const selectProviderIds = activeAttachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
const effectiveAllows: EffectiveAllowRow[] = [];
|
||||||
|
|
||||||
|
if (inheritProviderIds.length > 0) {
|
||||||
|
const providerAllows = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiModels.providerId, inheritProviderIds),
|
||||||
|
eq(aiModels.enabled, true),
|
||||||
|
eq(aiModels.listType, "allow")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
effectiveAllows.push(...providerAllows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectProviderIds.length > 0) {
|
||||||
|
if (options.selectedAllowsByProvider) {
|
||||||
|
for (const providerId of selectProviderIds) {
|
||||||
|
const keys =
|
||||||
|
options.selectedAllowsByProvider.get(providerId) ?? [];
|
||||||
|
for (const modelKey of keys) {
|
||||||
|
effectiveAllows.push({ providerId, modelKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (options.resourceId !== undefined) {
|
||||||
|
const rows = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, options.resourceId),
|
||||||
|
inArray(aiModels.providerId, selectProviderIds),
|
||||||
|
eq(resourceAiModels.listType, "allow"),
|
||||||
|
eq(aiModels.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
effectiveAllows.push(...rows);
|
||||||
|
} else if (options.siteResourceId !== undefined) {
|
||||||
|
const rows = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(siteResourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResourceAiModels.siteResourceId,
|
||||||
|
options.siteResourceId
|
||||||
|
),
|
||||||
|
inArray(aiModels.providerId, selectProviderIds),
|
||||||
|
eq(siteResourceAiModels.listType, "allow"),
|
||||||
|
eq(aiModels.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
effectiveAllows.push(...rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictPairs: string[] = [];
|
||||||
|
for (let i = 0; i < effectiveAllows.length; i++) {
|
||||||
|
for (let j = i + 1; j < effectiveAllows.length; j++) {
|
||||||
|
const left = effectiveAllows[i];
|
||||||
|
const right = effectiveAllows[j];
|
||||||
|
if (left.providerId === right.providerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!modelKeysConflict(left.modelKey, right.modelKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pair = [left.modelKey, right.modelKey].sort().join(" vs ");
|
||||||
|
if (!conflictPairs.includes(pair)) {
|
||||||
|
conflictPairs.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictPairs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
conflictPairs.sort();
|
||||||
|
return {
|
||||||
|
error: `Model keys must be unique across providers on a resource. Overlapping keys: ${conflictPairs.join(", ")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate provider attachments for an org.
|
||||||
|
*/
|
||||||
|
export async function resolveProviderAttachments(input: {
|
||||||
|
orgId: string;
|
||||||
|
attachments: ResourceAiProviderInput[];
|
||||||
|
requireAtLeastOne: boolean;
|
||||||
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
|
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
|
||||||
|
const attachments = normalizeAttachments(input.attachments);
|
||||||
|
|
||||||
|
if (input.requireAtLeastOne && attachments.length === 0) {
|
||||||
|
return {
|
||||||
|
error: "At least one AI provider is required for inference-mode resources"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerIds = attachments.map((a) => a.providerId);
|
||||||
|
const providers = await db
|
||||||
|
.select({
|
||||||
|
providerId: aiProviders.providerId,
|
||||||
|
orgId: aiProviders.orgId,
|
||||||
|
enabled: aiProviders.enabled
|
||||||
|
})
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiProviders.providerId, providerIds),
|
||||||
|
eq(aiProviders.orgId, input.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providers.length !== providerIds.length) {
|
||||||
|
return {
|
||||||
|
error: "One or more AI providers were not found in this organization"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = providers.find((p) => !p.enabled);
|
||||||
|
if (disabled) {
|
||||||
|
return {
|
||||||
|
error: `AI provider with ID ${disabled.providerId} is disabled`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapError = await assertNoOverlappingModelKeys(attachments, {
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
siteResourceId: input.siteResourceId
|
||||||
|
});
|
||||||
|
if (overlapError) {
|
||||||
|
return overlapError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertInferenceModeAllowsProviderFields(input: {
|
||||||
|
mode: string;
|
||||||
|
hasProviderAttachments: boolean;
|
||||||
|
}): Promise<InferenceFieldsError | null> {
|
||||||
|
if (input.mode === "inference") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.hasProviderAttachments) {
|
||||||
|
return {
|
||||||
|
error: "AI providers can only be attached to inference-mode resources"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach providers to a resource. Inherit attachments use the provider lists
|
||||||
|
* as-is (resource model rows for those providers are pruned). Select
|
||||||
|
* attachments keep resource-selected allow/block subsets.
|
||||||
|
*/
|
||||||
|
export async function setPublicResourceAiProviders(
|
||||||
|
resourceId: number,
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx = db
|
||||||
|
): Promise<void> {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiProviders)
|
||||||
|
.where(eq(resourceAiProviders.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
await trx.insert(resourceAiProviders).values(
|
||||||
|
attachments.map((a) => ({
|
||||||
|
resourceId,
|
||||||
|
providerId: a.providerId,
|
||||||
|
accessMode: a.accessMode,
|
||||||
|
enabled: a.enabled
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prunePublicResourceModelsToSelectProviders(
|
||||||
|
resourceId,
|
||||||
|
attachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSiteResourceAiProviders(
|
||||||
|
siteResourceId: number,
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx = db
|
||||||
|
): Promise<void> {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiProviders)
|
||||||
|
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||||
|
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
await trx.insert(siteResourceAiProviders).values(
|
||||||
|
attachments.map((a) => ({
|
||||||
|
siteResourceId,
|
||||||
|
providerId: a.providerId,
|
||||||
|
accessMode: a.accessMode,
|
||||||
|
enabled: a.enabled
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruneSiteResourceModelsToSelectProviders(
|
||||||
|
siteResourceId,
|
||||||
|
attachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep resource model rows only for providers in select mode.
|
||||||
|
*/
|
||||||
|
async function prunePublicResourceModelsToSelectProviders(
|
||||||
|
resourceId: number,
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx
|
||||||
|
): Promise<void> {
|
||||||
|
const selectProviderIds = attachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (selectProviderIds.length === 0) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await trx
|
||||||
|
.select({
|
||||||
|
modelId: resourceAiModels.modelId,
|
||||||
|
providerId: aiModels.providerId
|
||||||
|
})
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
|
||||||
|
const allowed = new Set(selectProviderIds);
|
||||||
|
const toRemove = existing
|
||||||
|
.filter((row) => !allowed.has(row.providerId))
|
||||||
|
.map((row) => row.modelId);
|
||||||
|
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, resourceId),
|
||||||
|
inArray(resourceAiModels.modelId, toRemove)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneSiteResourceModelsToSelectProviders(
|
||||||
|
siteResourceId: number,
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx
|
||||||
|
): Promise<void> {
|
||||||
|
const selectProviderIds = attachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (selectProviderIds.length === 0) {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiModels)
|
||||||
|
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await trx
|
||||||
|
.select({
|
||||||
|
modelId: siteResourceAiModels.modelId,
|
||||||
|
providerId: aiModels.providerId
|
||||||
|
})
|
||||||
|
.from(siteResourceAiModels)
|
||||||
|
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
|
||||||
|
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||||
|
|
||||||
|
const allowed = new Set(selectProviderIds);
|
||||||
|
const toRemove = existing
|
||||||
|
.filter((row) => !allowed.has(row.providerId))
|
||||||
|
.map((row) => row.modelId);
|
||||||
|
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResourceAiModels.siteResourceId, siteResourceId),
|
||||||
|
inArray(siteResourceAiModels.modelId, toRemove)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPublicResourceAiConfig(
|
||||||
|
resourceId: number,
|
||||||
|
trx: DbOrTrx = db
|
||||||
|
): Promise<void> {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiProviders)
|
||||||
|
.where(eq(resourceAiProviders.resourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSiteResourceAiConfig(
|
||||||
|
siteResourceId: number,
|
||||||
|
trx: DbOrTrx = db
|
||||||
|
): Promise<void> {
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiModels)
|
||||||
|
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||||
|
await trx
|
||||||
|
.delete(siteResourceAiProviders)
|
||||||
|
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPublicResourceAiProviders(resourceId: number) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
providerId: resourceAiProviders.providerId,
|
||||||
|
name: aiProviders.name,
|
||||||
|
type: aiProviders.type,
|
||||||
|
enabled: resourceAiProviders.enabled,
|
||||||
|
providerEnabled: aiProviders.enabled,
|
||||||
|
accessMode: resourceAiProviders.accessMode
|
||||||
|
})
|
||||||
|
.from(resourceAiProviders)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(eq(resourceAiProviders.resourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSiteResourceAiProviders(siteResourceId: number) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
providerId: siteResourceAiProviders.providerId,
|
||||||
|
name: aiProviders.name,
|
||||||
|
type: aiProviders.type,
|
||||||
|
enabled: siteResourceAiProviders.enabled,
|
||||||
|
providerEnabled: aiProviders.enabled,
|
||||||
|
accessMode: siteResourceAiProviders.accessMode
|
||||||
|
})
|
||||||
|
.from(siteResourceAiProviders)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model list APIs require an inference resource with at least one select-mode
|
||||||
|
* attached provider.
|
||||||
|
*/
|
||||||
|
export async function assertPublicModelListApiEligible(resource: {
|
||||||
|
resourceId: number;
|
||||||
|
mode: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
if (resource.mode !== "inference") {
|
||||||
|
return "AI model lists are only supported on inference-mode resources";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ providerId: resourceAiProviders.providerId })
|
||||||
|
.from(resourceAiProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiProviders.resourceId, resource.resourceId),
|
||||||
|
eq(resourceAiProviders.accessMode, "select")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertSiteModelListApiEligible(siteResource: {
|
||||||
|
siteResourceId: number;
|
||||||
|
mode: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
if (siteResource.mode !== "inference") {
|
||||||
|
return "AI model lists are only supported on inference-mode resources";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ providerId: siteResourceAiProviders.providerId })
|
||||||
|
.from(siteResourceAiProviders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResourceAiProviders.siteResourceId,
|
||||||
|
siteResource.siteResourceId
|
||||||
|
),
|
||||||
|
eq(siteResourceAiProviders.accessMode, "select")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource model entries must belong to select-mode attached providers, and
|
||||||
|
* listType must match the provider catalog entry (allow→allow, block→block).
|
||||||
|
*/
|
||||||
|
export async function assertPublicResourceModelEntriesValid(input: {
|
||||||
|
orgId: string;
|
||||||
|
resourceId: number;
|
||||||
|
models: ResourceAiModelEntry[];
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const uniqueModels = dedupeModelEntries(input.models);
|
||||||
|
if (uniqueModels.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await db
|
||||||
|
.select({
|
||||||
|
providerId: resourceAiProviders.providerId,
|
||||||
|
accessMode: resourceAiProviders.accessMode,
|
||||||
|
enabled: resourceAiProviders.enabled
|
||||||
|
})
|
||||||
|
.from(resourceAiProviders)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiProviders.resourceId, input.resourceId),
|
||||||
|
eq(aiProviders.orgId, input.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return assertModelEntriesValid({
|
||||||
|
orgId: input.orgId,
|
||||||
|
modelEntries: uniqueModels,
|
||||||
|
attachments,
|
||||||
|
resourceLabel: "resource"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertSiteResourceModelEntriesValid(input: {
|
||||||
|
orgId: string;
|
||||||
|
siteResourceId: number;
|
||||||
|
models: ResourceAiModelEntry[];
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const uniqueModels = dedupeModelEntries(input.models);
|
||||||
|
if (uniqueModels.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await db
|
||||||
|
.select({
|
||||||
|
providerId: siteResourceAiProviders.providerId,
|
||||||
|
accessMode: siteResourceAiProviders.accessMode,
|
||||||
|
enabled: siteResourceAiProviders.enabled
|
||||||
|
})
|
||||||
|
.from(siteResourceAiProviders)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResourceAiProviders.siteResourceId,
|
||||||
|
input.siteResourceId
|
||||||
|
),
|
||||||
|
eq(aiProviders.orgId, input.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return assertModelEntriesValid({
|
||||||
|
orgId: input.orgId,
|
||||||
|
modelEntries: uniqueModels,
|
||||||
|
attachments,
|
||||||
|
resourceLabel: "site resource"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeModelEntries(
|
||||||
|
models: ResourceAiModelEntry[]
|
||||||
|
): ResourceAiModelEntry[] {
|
||||||
|
const byModelId = new Map(
|
||||||
|
models.map((m) => [m.modelId, m.listType] as const)
|
||||||
|
);
|
||||||
|
return [...byModelId.entries()].map(([modelId, listType]) => ({
|
||||||
|
modelId,
|
||||||
|
listType
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertModelEntriesValid(input: {
|
||||||
|
orgId: string;
|
||||||
|
modelEntries: ResourceAiModelEntry[];
|
||||||
|
attachments: ResourceAiProviderAttachment[];
|
||||||
|
resourceLabel: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const selectProviderIds = input.attachments
|
||||||
|
.filter((a) => a.accessMode === "select")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (selectProviderIds.length === 0) {
|
||||||
|
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelIds = input.modelEntries.map((m) => m.modelId);
|
||||||
|
const catalogRows = await db
|
||||||
|
.select({
|
||||||
|
modelId: aiModels.modelId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
listType: aiModels.listType,
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiModels.modelId, modelIds),
|
||||||
|
inArray(aiModels.providerId, selectProviderIds),
|
||||||
|
eq(aiProviders.orgId, input.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (catalogRows.length !== modelIds.length) {
|
||||||
|
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||||
|
const selectedAllowsByProvider = new Map<number, string[]>();
|
||||||
|
for (const entry of input.modelEntries) {
|
||||||
|
const catalog = catalogById.get(entry.modelId);
|
||||||
|
if (!catalog) {
|
||||||
|
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
|
||||||
|
}
|
||||||
|
if (catalog.listType !== entry.listType) {
|
||||||
|
return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`;
|
||||||
|
}
|
||||||
|
if (!catalog.enabled) {
|
||||||
|
return `Model ${entry.modelId} is disabled on its provider`;
|
||||||
|
}
|
||||||
|
if (entry.listType === "allow") {
|
||||||
|
const keys = selectedAllowsByProvider.get(catalog.providerId) ?? [];
|
||||||
|
keys.push(catalog.modelKey);
|
||||||
|
selectedAllowsByProvider.set(catalog.providerId, keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapError = await assertNoOverlappingModelKeys(input.attachments, {
|
||||||
|
selectedAllowsByProvider
|
||||||
|
});
|
||||||
|
if (overlapError) {
|
||||||
|
return overlapError.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const modelKeyRegexCache = new Map<string, RegExp>();
|
||||||
|
|
||||||
|
export function isModelKeyPattern(key: string): boolean {
|
||||||
|
return key.includes("*") || key.includes("?");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelKeyRegex(pattern: string): RegExp {
|
||||||
|
let regex = modelKeyRegexCache.get(pattern);
|
||||||
|
if (!regex) {
|
||||||
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||||
|
regex = new RegExp(
|
||||||
|
`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`
|
||||||
|
);
|
||||||
|
modelKeyRegexCache.set(pattern, regex);
|
||||||
|
}
|
||||||
|
return regex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelKeyMatches(
|
||||||
|
pattern: string,
|
||||||
|
requestedModel: string
|
||||||
|
): boolean {
|
||||||
|
return getModelKeyRegex(pattern).test(requestedModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wildcardCharCount(key: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const char of key) {
|
||||||
|
if (char === "*" || char === "?") {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function literalLength(key: string): number {
|
||||||
|
return key.replace(/[*?]/g, "").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort comparator: more specific patterns sort before less specific ones
|
||||||
|
* (negative when `a` is more specific than `b`).
|
||||||
|
*
|
||||||
|
* 1. Exact keys beat patterns
|
||||||
|
* 2. Fewer wildcard characters win
|
||||||
|
* 3. Longer literal length wins
|
||||||
|
*/
|
||||||
|
export function compareModelKeySpecificity(a: string, b: string): number {
|
||||||
|
const aIsPattern = isModelKeyPattern(a);
|
||||||
|
const bIsPattern = isModelKeyPattern(b);
|
||||||
|
|
||||||
|
if (aIsPattern !== bIsPattern) {
|
||||||
|
return aIsPattern ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b);
|
||||||
|
if (wildcardDiff !== 0) {
|
||||||
|
return wildcardDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return literalLength(b) - literalLength(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach-time conflict check. Detects identical keys and exact-vs-pattern
|
||||||
|
* matches. Does not attempt full glob intersection.
|
||||||
|
*/
|
||||||
|
export function modelKeysConflict(a: string, b: string): boolean {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aIsPattern = isModelKeyPattern(a);
|
||||||
|
const bIsPattern = isModelKeyPattern(b);
|
||||||
|
|
||||||
|
if (aIsPattern === bIsPattern) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aIsPattern) {
|
||||||
|
return modelKeyMatches(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelKeyMatches(b, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
|
||||||
|
* after an allow match.
|
||||||
|
*/
|
||||||
|
export function isAllowedByLists(
|
||||||
|
requested: string,
|
||||||
|
allows: string[],
|
||||||
|
blocks: string[]
|
||||||
|
): boolean {
|
||||||
|
if (allows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Among allow patterns that match `requested`, return the most specific one,
|
||||||
|
* or null if none match.
|
||||||
|
*/
|
||||||
|
export function mostSpecificMatchingAllow(
|
||||||
|
requested: string,
|
||||||
|
allows: string[]
|
||||||
|
): string | null {
|
||||||
|
const matching = allows.filter((pattern) =>
|
||||||
|
modelKeyMatches(pattern, requested)
|
||||||
|
);
|
||||||
|
if (matching.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
matching.sort(compareModelKeySpecificity);
|
||||||
|
return matching[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
|
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||||
|
import type { AiUsage } from "@server/lib/aiUsageExtraction";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
// config/models.json is a runtime asset (same category as config.yml or the
|
||||||
|
// MaxMind DBs) - not part of the source tree. Its shape mirrors litellm's
|
||||||
|
// public model_prices_and_context_window.json: a flat list of
|
||||||
|
// { id, name, provider, input_cost_per_token, output_cost_per_token,
|
||||||
|
// cache_read_input_token_cost, output_cost_per_reasoning_token }, where
|
||||||
|
// `provider` is litellm's provider bucket, not our AiProviderType.
|
||||||
|
const MODELS_JSON_PATH = path.join(APP_PATH, "models.json");
|
||||||
|
|
||||||
|
export type AiModelPricingEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
input_cost_per_token: number | null;
|
||||||
|
output_cost_per_token: number | null;
|
||||||
|
cache_read_input_token_cost: number | null;
|
||||||
|
output_cost_per_reasoning_token: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiModelPricing = {
|
||||||
|
inputCostPerToken: number | null;
|
||||||
|
outputCostPerToken: number | null;
|
||||||
|
cacheReadInputTokenCost: number | null;
|
||||||
|
outputCostPerReasoningToken: number | null;
|
||||||
|
// True when the match came from a different provider bucket than the one
|
||||||
|
// mapped to this provider's type (e.g. an openRouter/custom model id that
|
||||||
|
// only matched by stripping a "vendor/" prefix against the whole table).
|
||||||
|
// Costs found this way are a best-effort approximation, not a guarantee
|
||||||
|
// the upstream provider bills at the same rate.
|
||||||
|
approximate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which litellm provider buckets to search for each of our provider types.
|
||||||
|
// Several of our provider types (openRouter, vercelAiGateway, custom) proxy
|
||||||
|
// arbitrary underlying models and have no dedicated bucket in the pricing
|
||||||
|
// data, so they fall back to a global search across all buckets.
|
||||||
|
const PROVIDER_PRICING_BUCKETS: Record<
|
||||||
|
Exclude<AiProviderType, "custom">,
|
||||||
|
string[]
|
||||||
|
> = {
|
||||||
|
openai: ["openai"],
|
||||||
|
anthropic: ["anthropic"],
|
||||||
|
googleGemini: ["gemini"],
|
||||||
|
vertexAi: [
|
||||||
|
"vertex_ai-language-models",
|
||||||
|
"vertex_ai",
|
||||||
|
"vertex_ai-anthropic_models",
|
||||||
|
"vertex_ai-mistral_models",
|
||||||
|
"vertex_ai-deepseek_models",
|
||||||
|
"vertex_ai-ai21_models",
|
||||||
|
"vertex_ai-llama_models",
|
||||||
|
"vertex_ai-minimax_models",
|
||||||
|
"vertex_ai-moonshot_models",
|
||||||
|
"vertex_ai-zai_models",
|
||||||
|
"vertex_ai-openai_models",
|
||||||
|
"vertex_ai-qwen_models",
|
||||||
|
"vertex_ai-text-models"
|
||||||
|
],
|
||||||
|
bedrock: ["bedrock_converse", "bedrock", "bedrock_mantle"],
|
||||||
|
microsoftFoundry: ["azure", "azure_ai", "azure_text"],
|
||||||
|
openRouter: [],
|
||||||
|
vercelAiGateway: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let modelsById: Map<string, AiModelPricingEntry[]> | null = null;
|
||||||
|
|
||||||
|
function loadModels(): Map<string, AiModelPricingEntry[]> {
|
||||||
|
if (modelsById) {
|
||||||
|
return modelsById;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map<string, AiModelPricingEntry[]>();
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(MODELS_JSON_PATH)) {
|
||||||
|
const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as { data: AiModelPricingEntry[] };
|
||||||
|
for (const entry of parsed.data ?? []) {
|
||||||
|
for (const key of [entry.id, entry.name]) {
|
||||||
|
if (!key) continue;
|
||||||
|
const list = byId.get(key) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
byId.set(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`AI model pricing file not found at ${MODELS_JSON_PATH}; cost calculation will fall back to unknown pricing`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Failed to load AI model pricing file", { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsById = byId;
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripVendorPrefix(modelId: string): string | null {
|
||||||
|
const idx = modelId.indexOf("/");
|
||||||
|
if (idx === -1 || idx === modelId.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return modelId.slice(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPricing(
|
||||||
|
entry: AiModelPricingEntry,
|
||||||
|
approximate: boolean
|
||||||
|
): AiModelPricing {
|
||||||
|
return {
|
||||||
|
inputCostPerToken: entry.input_cost_per_token,
|
||||||
|
outputCostPerToken: entry.output_cost_per_token,
|
||||||
|
cacheReadInputTokenCost: entry.cache_read_input_token_cost,
|
||||||
|
outputCostPerReasoningToken: entry.output_cost_per_reasoning_token,
|
||||||
|
approximate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInBuckets(
|
||||||
|
byId: Map<string, AiModelPricingEntry[]>,
|
||||||
|
modelId: string,
|
||||||
|
buckets: string[] | null
|
||||||
|
): AiModelPricingEntry | null {
|
||||||
|
const candidates = [modelId, stripVendorPrefix(modelId)].filter(
|
||||||
|
(v): v is string => v != null
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const entries = byId.get(key);
|
||||||
|
if (!entries) continue;
|
||||||
|
const match = buckets
|
||||||
|
? entries.find((e) => buckets.includes(e.provider))
|
||||||
|
: entries[0];
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up per-token pricing for a model, scoped first to the litellm
|
||||||
|
* provider bucket(s) that correspond to our provider type, then falling
|
||||||
|
* back to a global search across all buckets (marked `approximate`) for
|
||||||
|
* provider types that proxy arbitrary underlying models.
|
||||||
|
*/
|
||||||
|
export function getModelPricing(
|
||||||
|
providerType: AiProviderType,
|
||||||
|
modelId: string | undefined
|
||||||
|
): AiModelPricing | null {
|
||||||
|
if (!modelId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = loadModels();
|
||||||
|
const buckets =
|
||||||
|
providerType === "custom"
|
||||||
|
? []
|
||||||
|
: PROVIDER_PRICING_BUCKETS[providerType];
|
||||||
|
|
||||||
|
if (buckets && buckets.length > 0) {
|
||||||
|
const scoped = findInBuckets(byId, modelId, buckets);
|
||||||
|
if (scoped) {
|
||||||
|
return toPricing(scoped, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = findInBuckets(byId, modelId, null);
|
||||||
|
if (fallback) {
|
||||||
|
return toPricing(fallback, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiCostBreakdown = {
|
||||||
|
promptCost: number;
|
||||||
|
cacheReadCost: number;
|
||||||
|
cacheWriteCost: number;
|
||||||
|
completionCost: number;
|
||||||
|
reasoningCost: number;
|
||||||
|
totalCost: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a $ cost breakdown for a usage record given a model's pricing.
|
||||||
|
* Cache writes and reasoning tokens fall back to the normal input/output
|
||||||
|
* rate respectively when the pricing data has no dedicated rate for them
|
||||||
|
* (the models.json schema here has no cache-write field at all, and only
|
||||||
|
* some models report a distinct reasoning rate).
|
||||||
|
*/
|
||||||
|
export function calculateAiCost(
|
||||||
|
pricing: AiModelPricing | null,
|
||||||
|
usage: AiUsage
|
||||||
|
): AiCostBreakdown | null {
|
||||||
|
if (!pricing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputRate = pricing.inputCostPerToken ?? 0;
|
||||||
|
const outputRate = pricing.outputCostPerToken ?? 0;
|
||||||
|
const cacheReadRate = pricing.cacheReadInputTokenCost ?? inputRate;
|
||||||
|
const reasoningRate = pricing.outputCostPerReasoningToken ?? outputRate;
|
||||||
|
|
||||||
|
const promptCost = usage.promptTokens * inputRate;
|
||||||
|
const cacheReadCost = usage.cacheReadTokens * cacheReadRate;
|
||||||
|
const cacheWriteCost = usage.cacheWriteTokens * inputRate;
|
||||||
|
const completionCost = usage.completionTokens * outputRate;
|
||||||
|
const reasoningCost = usage.reasoningTokens * reasoningRate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptCost,
|
||||||
|
cacheReadCost,
|
||||||
|
cacheWriteCost,
|
||||||
|
completionCost,
|
||||||
|
reasoningCost,
|
||||||
|
totalCost:
|
||||||
|
promptCost +
|
||||||
|
cacheReadCost +
|
||||||
|
cacheWriteCost +
|
||||||
|
completionCost +
|
||||||
|
reasoningCost
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { decrypt, encrypt } from "@server/lib/crypto";
|
||||||
|
import {
|
||||||
|
parseCapabilities,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
|
export type AiProviderType =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "googleGemini"
|
||||||
|
| "vertexAi"
|
||||||
|
| "bedrock"
|
||||||
|
| "microsoftFoundry"
|
||||||
|
| "openRouter"
|
||||||
|
| "vercelAiGateway"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export const AI_PROVIDER_AUTH_TYPES = [
|
||||||
|
"bearer",
|
||||||
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"hec",
|
||||||
|
"cf-aig-authorization",
|
||||||
|
"none",
|
||||||
|
"passthrough"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
|
||||||
|
export type AiBudgetUnit = "usd" | "tokens";
|
||||||
|
export type AiProviderRoutingMode = "url" | "target";
|
||||||
|
|
||||||
|
type AiProviderDefaults = {
|
||||||
|
upstreamUrl: string | null;
|
||||||
|
authType: AiProviderAuthType;
|
||||||
|
capabilities: readonly AiCapability[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AI_PROVIDER_DEFAULTS: Record<
|
||||||
|
Exclude<AiProviderType, "custom">,
|
||||||
|
AiProviderDefaults
|
||||||
|
> = {
|
||||||
|
openai: {
|
||||||
|
upstreamUrl: "https://api.openai.com/v1",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat", "openai_responses"]
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
upstreamUrl: "https://api.anthropic.com",
|
||||||
|
authType: "x-api-key",
|
||||||
|
capabilities: ["anthropic_messages"]
|
||||||
|
},
|
||||||
|
googleGemini: {
|
||||||
|
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||||
|
authType: "x-goog-api-key",
|
||||||
|
capabilities: ["gemini_generate_content"]
|
||||||
|
},
|
||||||
|
vertexAi: {
|
||||||
|
upstreamUrl: null,
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["google_generate_content", "google_raw_predict"]
|
||||||
|
},
|
||||||
|
bedrock: {
|
||||||
|
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["bedrock_converse"]
|
||||||
|
},
|
||||||
|
microsoftFoundry: {
|
||||||
|
upstreamUrl: null,
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
||||||
|
},
|
||||||
|
openRouter: {
|
||||||
|
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat"]
|
||||||
|
},
|
||||||
|
vercelAiGateway: {
|
||||||
|
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat", "openai_responses"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFLICTING_AUTH_HEADERS = [
|
||||||
|
"authorization",
|
||||||
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"cf-aig-authorization"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
|
||||||
|
return authType !== "none" && authType !== "passthrough";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerRequiresUpstreamUrl(
|
||||||
|
type: AiProviderType,
|
||||||
|
routingMode: AiProviderRoutingMode = "url"
|
||||||
|
): boolean {
|
||||||
|
if (routingMode === "target") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type === "custom") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAiProviderCreateFields(input: {
|
||||||
|
type: AiProviderType;
|
||||||
|
upstreamUrl?: string | null;
|
||||||
|
authType?: AiProviderAuthType | null;
|
||||||
|
routingMode?: AiProviderRoutingMode | null;
|
||||||
|
}): {
|
||||||
|
upstreamUrl: string | null;
|
||||||
|
authType: AiProviderAuthType;
|
||||||
|
routingMode: AiProviderRoutingMode;
|
||||||
|
} {
|
||||||
|
const routingMode =
|
||||||
|
input.type === "custom" ? (input.routingMode ?? "url") : "url";
|
||||||
|
|
||||||
|
if (routingMode === "target") {
|
||||||
|
return {
|
||||||
|
upstreamUrl: null,
|
||||||
|
authType: input.authType ?? "bearer",
|
||||||
|
routingMode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.type === "custom") {
|
||||||
|
return {
|
||||||
|
upstreamUrl: input.upstreamUrl ?? null,
|
||||||
|
authType: input.authType ?? "bearer",
|
||||||
|
routingMode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = AI_PROVIDER_DEFAULTS[input.type];
|
||||||
|
return {
|
||||||
|
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
|
||||||
|
authType: input.authType ?? defaults.authType,
|
||||||
|
routingMode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiProviderHeader = { name: string; value: string };
|
||||||
|
|
||||||
|
export function serializeAiProviderHeaders(
|
||||||
|
headers: AiProviderHeader[] | null | undefined,
|
||||||
|
secret: string
|
||||||
|
): string | null {
|
||||||
|
if (!headers || headers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return encrypt(JSON.stringify(headers), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAiProviderHeaders(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
secret: string
|
||||||
|
): AiProviderHeader[] {
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decrypted = decrypt(raw, secret);
|
||||||
|
const parsed = JSON.parse(decrypted);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter(
|
||||||
|
(h): h is AiProviderHeader =>
|
||||||
|
h != null &&
|
||||||
|
typeof h === "object" &&
|
||||||
|
typeof h.name === "string" &&
|
||||||
|
typeof h.value === "string"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAiProviderCustomHeaders(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
raw: string | null | undefined,
|
||||||
|
secret: string
|
||||||
|
): void {
|
||||||
|
for (const { name, value } of parseAiProviderHeaders(raw, secret)) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply provider auth to upstream headers.
|
||||||
|
* - Injected modes: strip client auth headers, then set the provider key.
|
||||||
|
* - none: strip client auth headers, send no auth.
|
||||||
|
* - passthrough: leave client auth headers as-is.
|
||||||
|
*/
|
||||||
|
export function applyAiProviderAuthHeaders(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
authType: AiProviderAuthType,
|
||||||
|
apiKey: string | null
|
||||||
|
): void {
|
||||||
|
if (authType === "passthrough") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of CONFLICTING_AUTH_HEADERS) {
|
||||||
|
for (const key of Object.keys(headers)) {
|
||||||
|
if (key.toLowerCase() === name) {
|
||||||
|
delete headers[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(`API key required for authType ${authType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (authType) {
|
||||||
|
case "bearer":
|
||||||
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||||
|
break;
|
||||||
|
case "x-api-key":
|
||||||
|
headers["x-api-key"] = apiKey;
|
||||||
|
break;
|
||||||
|
case "x-goog-api-key":
|
||||||
|
headers["x-goog-api-key"] = apiKey;
|
||||||
|
break;
|
||||||
|
case "hec":
|
||||||
|
headers["Authorization"] = `Splunk ${apiKey}`;
|
||||||
|
break;
|
||||||
|
case "cf-aig-authorization":
|
||||||
|
headers["cf-aig-authorization"] = `Bearer ${apiKey}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCapabilitiesForCreate(input: {
|
||||||
|
type: AiProviderType;
|
||||||
|
capabilities?: AiCapability[] | null;
|
||||||
|
}): AiCapability[] {
|
||||||
|
if (input.capabilities != null) {
|
||||||
|
return parseCapabilities(input.capabilities);
|
||||||
|
}
|
||||||
|
if (input.type === "custom") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultsForProviderType(
|
||||||
|
type: AiProviderType
|
||||||
|
): readonly AiCapability[] {
|
||||||
|
if (type === "custom") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return AI_PROVIDER_DEFAULTS[type].capabilities;
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { encode } from "gpt-tokenizer";
|
||||||
|
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export type AiUsage = {
|
||||||
|
// Input tokens billed at the normal input rate (i.e. NOT already
|
||||||
|
// covered by cacheReadTokens/cacheWriteTokens below).
|
||||||
|
promptTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
// Output tokens billed at the normal output rate (i.e. NOT already
|
||||||
|
// covered by reasoningTokens below).
|
||||||
|
completionTokens: number;
|
||||||
|
reasoningTokens: number;
|
||||||
|
// True when these numbers are our own best-guess estimate (the upstream
|
||||||
|
// response didn't report usage), rather than provider-reported figures.
|
||||||
|
estimated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyUsage(): AiUsage {
|
||||||
|
return {
|
||||||
|
promptTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans raw (possibly binary-framed, e.g. Bedrock's vnd.amazon.eventstream)
|
||||||
|
* text for `"fieldName":123` occurrences and returns the last value seen for
|
||||||
|
* each field. Used as a best-effort fallback for response shapes we can't
|
||||||
|
* fully parse as JSON/SSE (streaming Bedrock, raw predict passthroughs).
|
||||||
|
*/
|
||||||
|
function scanNumericFields(
|
||||||
|
text: string,
|
||||||
|
fields: string[]
|
||||||
|
): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const re = new RegExp(`"${field}"\\s*:\\s*(\\d+)`, "g");
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(text)) !== null) {
|
||||||
|
out[field] = Number(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sseDataFrames(text: string): string[] {
|
||||||
|
const frames: string[] = [];
|
||||||
|
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
|
||||||
|
for (const line of rawFrame.split(/\r?\n/)) {
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
const data = line.slice("data:".length).trim();
|
||||||
|
if (data && data !== "[DONE]") {
|
||||||
|
frames.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(text: string): any | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenAiChat(text: string, isStream: boolean): AiUsage | null {
|
||||||
|
let usage: any = null;
|
||||||
|
|
||||||
|
if (isStream) {
|
||||||
|
for (const frame of sseDataFrames(text)) {
|
||||||
|
const parsed = tryParseJson(frame);
|
||||||
|
if (parsed?.usage) {
|
||||||
|
usage = parsed.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
usage = tryParseJson(text)?.usage ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||||
|
const reasoningTokens =
|
||||||
|
usage.completion_tokens_details?.reasoning_tokens ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cacheReadTokens),
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
completionTokens: Math.max(
|
||||||
|
0,
|
||||||
|
(usage.completion_tokens ?? 0) - reasoningTokens
|
||||||
|
),
|
||||||
|
reasoningTokens,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenAiResponses(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): AiUsage | null {
|
||||||
|
let usage: any = null;
|
||||||
|
|
||||||
|
if (isStream) {
|
||||||
|
for (const frame of sseDataFrames(text)) {
|
||||||
|
const parsed = tryParseJson(frame);
|
||||||
|
if (parsed?.type === "response.completed" && parsed?.response?.usage) {
|
||||||
|
usage = parsed.response.usage;
|
||||||
|
} else if (parsed?.usage) {
|
||||||
|
usage = parsed.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
usage = parsed?.usage ?? parsed?.response?.usage ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0;
|
||||||
|
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptTokens: Math.max(0, (usage.input_tokens ?? 0) - cacheReadTokens),
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
completionTokens: Math.max(
|
||||||
|
0,
|
||||||
|
(usage.output_tokens ?? 0) - reasoningTokens
|
||||||
|
),
|
||||||
|
reasoningTokens,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAnthropicMessages(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): AiUsage | null {
|
||||||
|
let inputTokens = 0;
|
||||||
|
let cacheReadTokens = 0;
|
||||||
|
let cacheWriteTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
const applyUsage = (usage: any) => {
|
||||||
|
if (!usage) return;
|
||||||
|
found = true;
|
||||||
|
if (typeof usage.input_tokens === "number") {
|
||||||
|
inputTokens = usage.input_tokens;
|
||||||
|
}
|
||||||
|
if (typeof usage.cache_read_input_tokens === "number") {
|
||||||
|
cacheReadTokens = usage.cache_read_input_tokens;
|
||||||
|
}
|
||||||
|
if (typeof usage.cache_creation_input_tokens === "number") {
|
||||||
|
cacheWriteTokens = usage.cache_creation_input_tokens;
|
||||||
|
}
|
||||||
|
if (typeof usage.output_tokens === "number") {
|
||||||
|
outputTokens = usage.output_tokens;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isStream) {
|
||||||
|
for (const frame of sseDataFrames(text)) {
|
||||||
|
const parsed = tryParseJson(frame);
|
||||||
|
if (!parsed) continue;
|
||||||
|
applyUsage(parsed.message?.usage);
|
||||||
|
applyUsage(parsed.usage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applyUsage(tryParseJson(text)?.usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptTokens: inputTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens,
|
||||||
|
completionTokens: outputTokens,
|
||||||
|
// Anthropic bills extended-thinking output at the normal output
|
||||||
|
// rate, so there's no separate reasoning bucket to report.
|
||||||
|
reasoningTokens: 0,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGoogleGenerateContent(
|
||||||
|
text: string,
|
||||||
|
_isStream: boolean
|
||||||
|
): AiUsage | null {
|
||||||
|
// Both the plain-JSON-array stream format and the SSE (?alt=sse) format
|
||||||
|
// repeat a cumulative `usageMetadata` object per chunk; the regex scan
|
||||||
|
// below naturally picks up the last (most complete) one either way.
|
||||||
|
const fields = scanNumericFields(text, [
|
||||||
|
"promptTokenCount",
|
||||||
|
"candidatesTokenCount",
|
||||||
|
"cachedContentTokenCount",
|
||||||
|
"thoughtsTokenCount"
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (fields.promptTokenCount === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheReadTokens = fields.cachedContentTokenCount ?? 0;
|
||||||
|
const reasoningTokens = fields.thoughtsTokenCount ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptTokens: Math.max(0, fields.promptTokenCount - cacheReadTokens),
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
completionTokens: fields.candidatesTokenCount ?? 0,
|
||||||
|
reasoningTokens,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBedrockConverse(
|
||||||
|
text: string,
|
||||||
|
_isStream: boolean
|
||||||
|
): AiUsage | null {
|
||||||
|
// Non-streaming responses are plain JSON; converse-stream frames the
|
||||||
|
// final `metadata` event's usage object inside binary event-stream
|
||||||
|
// framing, but the JSON text survives intact inside that binary
|
||||||
|
// envelope, so the same field scan works for both.
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
const usage = parsed?.usage;
|
||||||
|
if (usage) {
|
||||||
|
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
|
||||||
|
return {
|
||||||
|
promptTokens: Math.max(0, (usage.inputTokens ?? 0) - cacheReadTokens),
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
|
||||||
|
completionTokens: usage.outputTokens ?? 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = scanNumericFields(text, [
|
||||||
|
"inputTokens",
|
||||||
|
"outputTokens",
|
||||||
|
"cacheReadInputTokens",
|
||||||
|
"cacheWriteInputTokens"
|
||||||
|
]);
|
||||||
|
if (fields.inputTokens === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cacheReadTokens = fields.cacheReadInputTokens ?? 0;
|
||||||
|
return {
|
||||||
|
promptTokens: Math.max(0, fields.inputTokens - cacheReadTokens),
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens: fields.cacheWriteInputTokens ?? 0,
|
||||||
|
completionTokens: fields.outputTokens ?? 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBedrockModelInvoke(
|
||||||
|
text: string,
|
||||||
|
_isStream: boolean,
|
||||||
|
headers: Headers
|
||||||
|
): AiUsage | null {
|
||||||
|
// Non-streaming invoke reports counts via response headers regardless
|
||||||
|
// of the underlying model's payload format.
|
||||||
|
const headerInput = headers.get("x-amzn-bedrock-input-token-count");
|
||||||
|
const headerOutput = headers.get("x-amzn-bedrock-output-token-count");
|
||||||
|
if (headerInput !== null || headerOutput !== null) {
|
||||||
|
return {
|
||||||
|
promptTokens: Number(headerInput ?? 0),
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
completionTokens: Number(headerOutput ?? 0),
|
||||||
|
reasoningTokens: 0,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// invoke-with-response-stream has no equivalent headers; the model's
|
||||||
|
// own usage shape (frequently Anthropic-style on Bedrock) is embedded
|
||||||
|
// inside binary event-stream framing, so fall back to a couple of
|
||||||
|
// known field-name shapes via regex.
|
||||||
|
const anthropicStyle = extractAnthropicMessages(text, true);
|
||||||
|
if (anthropicStyle) {
|
||||||
|
return anthropicStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = scanNumericFields(text, [
|
||||||
|
"inputTokenCount",
|
||||||
|
"outputTokenCount"
|
||||||
|
]);
|
||||||
|
if (fields.inputTokenCount === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
promptTokens: fields.inputTokenCount,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
completionTokens: fields.outputTokenCount ?? 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTRACTORS: Record<
|
||||||
|
AiCapability,
|
||||||
|
(text: string, isStream: boolean, headers: Headers) => AiUsage | null
|
||||||
|
> = {
|
||||||
|
openai_chat: extractOpenAiChat,
|
||||||
|
openai_responses: extractOpenAiResponses,
|
||||||
|
anthropic_messages: extractAnthropicMessages,
|
||||||
|
gemini_generate_content: extractGoogleGenerateContent,
|
||||||
|
google_generate_content: extractGoogleGenerateContent,
|
||||||
|
// rawPredict is a passthrough to whatever the underlying publisher
|
||||||
|
// model speaks (often Anthropic-shaped on Vertex); try that, then give
|
||||||
|
// up to the token-count estimate.
|
||||||
|
google_raw_predict: (text, isStream) =>
|
||||||
|
extractAnthropicMessages(text, isStream),
|
||||||
|
bedrock_model_invoke: extractBedrockModelInvoke,
|
||||||
|
bedrock_converse: extractBedrockConverse
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to pull provider-reported token usage out of an upstream AI
|
||||||
|
* gateway response. Returns null if the response didn't contain (or we
|
||||||
|
* couldn't find) usage data, in which case callers should fall back to
|
||||||
|
* `estimateUsage`.
|
||||||
|
*/
|
||||||
|
export function extractUsage(
|
||||||
|
capability: AiCapability,
|
||||||
|
responseText: string,
|
||||||
|
isStream: boolean,
|
||||||
|
headers: Headers
|
||||||
|
): AiUsage | null {
|
||||||
|
try {
|
||||||
|
return EXTRACTORS[capability](responseText, isStream, headers);
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug("Failed to extract AI usage from response", {
|
||||||
|
capability,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-guess token estimate for when the provider doesn't report usage.
|
||||||
|
* Uses OpenAI's BPE tokenizer as a stand-in for whatever tokenizer the
|
||||||
|
* actual model uses - close enough for an approximate cost figure, not
|
||||||
|
* exact for non-OpenAI models.
|
||||||
|
*/
|
||||||
|
export function estimateUsage(
|
||||||
|
promptText: string,
|
||||||
|
completionText: string
|
||||||
|
): AiUsage {
|
||||||
|
const usage = emptyUsage();
|
||||||
|
usage.estimated = true;
|
||||||
|
try {
|
||||||
|
usage.promptTokens = promptText ? encode(promptText).length : 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug("Failed to estimate prompt tokens", { error });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
usage.completionTokens = completionText
|
||||||
|
? encode(completionText).length
|
||||||
|
: 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug("Failed to estimate completion tokens", { error });
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI's Chat Completions API only includes a `usage` field in a
|
||||||
|
* streaming response when the request opts in via `stream_options:
|
||||||
|
* {include_usage: true}` - unlike the Responses API, Anthropic, Gemini and
|
||||||
|
* Bedrock, which report usage in a streaming response by default. Returns
|
||||||
|
* whether we need to inject that option ourselves to be able to track cost.
|
||||||
|
*/
|
||||||
|
export function needsStreamUsageInjection(
|
||||||
|
capability: AiCapability,
|
||||||
|
body: any
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
capability === "openai_chat" &&
|
||||||
|
body?.stream === true &&
|
||||||
|
body?.stream_options?.include_usage !== true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a shallow-cloned body with `stream_options.include_usage`
|
||||||
|
* injected, for capabilities/requests where `needsStreamUsageInjection`
|
||||||
|
* is true. Leaves the original body untouched.
|
||||||
|
*/
|
||||||
|
export function withStreamUsageOption(body: any): any {
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
stream_options: { ...body.stream_options, include_usage: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When we injected stream_options.include_usage ourselves (the caller
|
||||||
|
* didn't ask for it), OpenAI appends an extra terminal SSE frame with an
|
||||||
|
* empty `choices: []` array carrying only the usage data. Callers that
|
||||||
|
* don't expect that shape (most minimal SSE parsers assume a non-empty
|
||||||
|
* choices array) shouldn't see it, so it's stripped back out of the bytes
|
||||||
|
* forwarded to the client.
|
||||||
|
*/
|
||||||
|
export function stripInjectedUsageFrame(sseText: string): string {
|
||||||
|
const parts = sseText.split(/(\r?\n\r?\n)/);
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < parts.length; i += 2) {
|
||||||
|
const frame = parts[i];
|
||||||
|
const separator = parts[i + 1] ?? "";
|
||||||
|
const dataLine = frame
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((line) => line.startsWith("data:"));
|
||||||
|
if (dataLine) {
|
||||||
|
const data = dataLine.slice("data:".length).trim();
|
||||||
|
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
|
||||||
|
if (parsed && Array.isArray(parsed.choices) && parsed.choices.length === 0 && parsed.usage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += frame + separator;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort extraction of the model the upstream provider actually
|
||||||
|
* served, which some gateways/routers echo back and which may differ from
|
||||||
|
* the model the caller requested (e.g. an alias resolving to a dated
|
||||||
|
* snapshot). Falls back to the caller's requested model when absent.
|
||||||
|
*/
|
||||||
|
export function extractResponseModel(responseText: string): string | null {
|
||||||
|
const match = responseText.match(/"model"\s*:\s*"([^"]+)"/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsageEmpty(usage: AiUsage): boolean {
|
||||||
|
return (
|
||||||
|
usage.promptTokens === 0 &&
|
||||||
|
usage.cacheReadTokens === 0 &&
|
||||||
|
usage.cacheWriteTokens === 0 &&
|
||||||
|
usage.completionTokens === 0 &&
|
||||||
|
usage.reasoningTokens === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -202,6 +202,10 @@ async function handleResource(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!target.resourceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [resource] = await trx
|
const [resource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -227,9 +231,7 @@ async function handleResource(
|
|||||||
|
|
||||||
let health = "healthy";
|
let health = "healthy";
|
||||||
const allUnknown = monitoredTargets.length === 0;
|
const allUnknown = monitoredTargets.length === 0;
|
||||||
const allHealthy = monitoredTargets.every(
|
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
|
||||||
(t) => t.hcHealth === "healthy"
|
|
||||||
);
|
|
||||||
const allUnhealthy = monitoredTargets.every(
|
const allUnhealthy = monitoredTargets.every(
|
||||||
(t) => t.hcHealth === "unhealthy"
|
(t) => t.hcHealth === "unhealthy"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get exit nodes for this org
|
|
||||||
const exitNodesList = await getExitNodes(orgId);
|
|
||||||
|
|
||||||
if (exitNodesList.length === 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
// Get next available subnet
|
// Get next available subnet
|
||||||
const { value: newSubnet, release: releaseSubnetLock } =
|
const { value: newSubnet, release: releaseSubnetLock } =
|
||||||
await getNextAvailableClientSubnet(orgId, trx);
|
await getNextAvailableClientSubnet(orgId, trx);
|
||||||
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
|
|||||||
const newClientData: InferInsertModel<typeof clients> = {
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
userId,
|
userId,
|
||||||
orgId: userOrg.orgId,
|
orgId: userOrg.orgId,
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
name: olm.name || "User Client",
|
||||||
subnet: updatedSubnet,
|
subnet: updatedSubnet,
|
||||||
olmId: olm.olmId,
|
olmId: olm.olmId,
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export async function deleteOrgById(
|
|||||||
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
||||||
}
|
}
|
||||||
for (const client of orgClients) {
|
for (const client of orgClients) {
|
||||||
|
if (client.exitNodeId && client.pubKey) {
|
||||||
|
await deletePeer(client.exitNodeId, client.pubKey);
|
||||||
|
}
|
||||||
const [olm] = await trx
|
const [olm] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(olms)
|
.from(olms)
|
||||||
|
|||||||
@@ -64,13 +64,20 @@ export async function performDeleteResources(
|
|||||||
|
|
||||||
const targetsByResourceId = new Map<number, Target[]>();
|
const targetsByResourceId = new Map<number, Target[]>();
|
||||||
for (const target of targetsToBeRemoved) {
|
for (const target of targetsToBeRemoved) {
|
||||||
|
if (target.resourceId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
||||||
existing.push(target);
|
existing.push(target);
|
||||||
targetsByResourceId.set(target.resourceId, existing);
|
targetsByResourceId.set(target.resourceId, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIdToResourceId = new Map(
|
const targetIdToResourceId = new Map(
|
||||||
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
|
targetsToBeRemoved.flatMap((target) =>
|
||||||
|
target.resourceId == null
|
||||||
|
? []
|
||||||
|
: [[target.targetId, target.resourceId] as const]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resources,
|
resources,
|
||||||
@@ -33,9 +33,11 @@ export async function getResourceIdsForSite(
|
|||||||
const rows = await trx
|
const rows = await trx
|
||||||
.selectDistinct({ resourceId: targets.resourceId })
|
.selectDistinct({ resourceId: targets.resourceId })
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.where(eq(targets.siteId, siteId));
|
.where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
|
||||||
|
|
||||||
return rows.map((row) => row.resourceId);
|
return rows
|
||||||
|
.map((row) => row.resourceId)
|
||||||
|
.filter((resourceId): resourceId is number => resourceId != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSiteResourceIdsForSite(
|
export async function getSiteResourceIdsForSite(
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { db, sites, clients } from "@server/db";
|
||||||
|
import { and, eq, count } from "drizzle-orm";
|
||||||
|
|
||||||
|
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
||||||
|
// higher = more desirable
|
||||||
|
// like saying, this node has x% of its capacity left
|
||||||
|
export async function calculateExitNodeWeight(
|
||||||
|
exitNodeId: number,
|
||||||
|
maxConnections: number | null | undefined
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (maxConnections === null || maxConnections === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[siteConnections], [clientConnections]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.exitNodeId, exitNodeId),
|
||||||
|
eq(clients.online, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentConnections = siteConnections.count + clientConnections.count;
|
||||||
|
|
||||||
|
if (currentConnections >= maxConnections) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (maxConnections - currentConnections) / maxConnections;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { db, exitNodes, Transaction } from "@server/db";
|
import { db, exitNodes, Transaction } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ExitNodePingResult } from "@server/routers/newt";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export async function verifyExitNodeOrgAccess(
|
export async function verifyExitNodeOrgAccess(
|
||||||
@@ -52,6 +51,16 @@ export async function listExitNodes(
|
|||||||
return allExitNodes;
|
return allExitNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExitNodePingResult = {
|
||||||
|
exitNodeId: number;
|
||||||
|
latencyMs: number;
|
||||||
|
weight: number;
|
||||||
|
error?: string;
|
||||||
|
exitNodeName: string;
|
||||||
|
endpoint: string;
|
||||||
|
wasPreviouslyConnected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function selectBestExitNode(
|
export function selectBestExitNode(
|
||||||
pingResults: ExitNodePingResult[]
|
pingResults: ExitNodePingResult[]
|
||||||
): ExitNodePingResult | null {
|
): ExitNodePingResult | null {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
|
export async function getUniqueSubnetForExitNode(
|
||||||
|
exitNode: ExitNode,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<string | null> {
|
||||||
|
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
||||||
|
|
||||||
|
return await lockManager.withLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
|
const [sitesQuery, clientsQuery] = await Promise.all([
|
||||||
|
trx
|
||||||
|
.select({ subnet: sites.exitNodeSubnet })
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
|
||||||
|
trx
|
||||||
|
.select({ subnet: clients.exitNodeSubnet })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||||
|
const subnets = [...sitesQuery, ...clientsQuery]
|
||||||
|
.map((row) => row.subnet)
|
||||||
|
.filter(
|
||||||
|
(subnet): subnet is string =>
|
||||||
|
!!subnet &&
|
||||||
|
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||||
|
);
|
||||||
|
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||||
|
|
||||||
|
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
|
||||||
|
},
|
||||||
|
5000 // 5 second lock TTL - subnet allocation should be quick
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export * from "./exitNodes";
|
|||||||
export * from "./exitNodeComms";
|
export * from "./exitNodeComms";
|
||||||
export * from "./subnet";
|
export * from "./subnet";
|
||||||
export * from "./getCurrentExitNodeId";
|
export * from "./getCurrentExitNodeId";
|
||||||
|
export * from "./calculateExitNodeWeight";
|
||||||
|
export * from "./getUniqueSubnetForExitNode";
|
||||||
|
|||||||
+21
-8
@@ -528,7 +528,10 @@ export function generateRemoteSubnets(
|
|||||||
|
|
||||||
export type Alias = { alias: string | null; aliasAddress: string | null };
|
export type Alias = { alias: string | null; aliasAddress: string | null };
|
||||||
|
|
||||||
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
export function generateAliasConfig(
|
||||||
|
allSiteResources: SiteResource[],
|
||||||
|
overrideIp?: string
|
||||||
|
): Alias[] {
|
||||||
return allSiteResources
|
return allSiteResources
|
||||||
.filter(
|
.filter(
|
||||||
(sr) =>
|
(sr) =>
|
||||||
@@ -539,7 +542,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
|||||||
)
|
)
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
alias: sr.alias || sr.fullDomain,
|
alias: sr.alias || sr.fullDomain,
|
||||||
aliasAddress: sr.aliasAddress
|
aliasAddress: overrideIp || sr.aliasAddress
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,9 +663,10 @@ export type CertRef = { id: string; cert: string; key: string };
|
|||||||
* certificate (e.g. a wildcard cert used by thousands of site resources)
|
* certificate (e.g. a wildcard cert used by thousands of site resources)
|
||||||
* only need that certificate sent once per sync message.
|
* only need that certificate sent once per sync message.
|
||||||
*/
|
*/
|
||||||
export function dedupeCertsForTargets(
|
export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
|
||||||
targetsV2: SubnetProxyTargetV2[]
|
targets: SubnetProxyTargetV2[];
|
||||||
): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } {
|
certs: CertRef[];
|
||||||
|
} {
|
||||||
const idByContent = new Map<string, string>();
|
const idByContent = new Map<string, string>();
|
||||||
const certs: CertRef[] = [];
|
const certs: CertRef[] = [];
|
||||||
|
|
||||||
@@ -674,7 +678,10 @@ export function dedupeCertsForTargets(
|
|||||||
const contentKey = `${target.tlsCert}|${target.tlsKey}`;
|
const contentKey = `${target.tlsCert}|${target.tlsKey}`;
|
||||||
let id = idByContent.get(contentKey);
|
let id = idByContent.get(contentKey);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16);
|
id = createHash("sha1")
|
||||||
|
.update(contentKey)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
idByContent.set(contentKey, id);
|
idByContent.set(contentKey, id);
|
||||||
certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
|
certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
|
||||||
}
|
}
|
||||||
@@ -708,7 +715,9 @@ export async function batchFetchCertsForSiteResources(
|
|||||||
): Promise<CertByDomain> {
|
): Promise<CertByDomain> {
|
||||||
const domains = new Set(
|
const domains = new Set(
|
||||||
allSiteResources
|
allSiteResources
|
||||||
.filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain)
|
.filter(
|
||||||
|
(r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain
|
||||||
|
)
|
||||||
.map((r) => r.fullDomain as string)
|
.map((r) => r.fullDomain as string)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -852,7 +861,11 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
new Set([siteResource.fullDomain]),
|
new Set([siteResource.fullDomain]),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
|
if (
|
||||||
|
certs.length > 0 &&
|
||||||
|
certs[0].certFile &&
|
||||||
|
certs[0].keyFile
|
||||||
|
) {
|
||||||
tlsCert = certs[0].certFile;
|
tlsCert = certs[0].certFile;
|
||||||
tlsKey = certs[0].keyFile;
|
tlsKey = certs[0].keyFile;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -79,7 +79,13 @@ export const configSchema = z
|
|||||||
.default(3001)
|
.default(3001)
|
||||||
.transform(stoi)
|
.transform(stoi)
|
||||||
.pipe(portSchema),
|
.pipe(portSchema),
|
||||||
|
ai_gateway_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.default(3005)
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
badger_override: z.string().optional(),
|
badger_override: z.string().optional(),
|
||||||
|
ai_gateway_override: z.string().optional(),
|
||||||
next_port: portSchema
|
next_port: portSchema
|
||||||
.optional()
|
.optional()
|
||||||
.default(3002)
|
.default(3002)
|
||||||
@@ -139,6 +145,7 @@ export const configSchema = z
|
|||||||
integration_port: 3003,
|
integration_port: 3003,
|
||||||
external_port: 3000,
|
external_port: 3000,
|
||||||
internal_port: 3001,
|
internal_port: 3001,
|
||||||
|
ai_gateway_port: 3005,
|
||||||
next_port: 3002,
|
next_port: 3002,
|
||||||
internal_hostname: "pangolin",
|
internal_hostname: "pangolin",
|
||||||
session_cookie_name: "p_session_token",
|
session_cookie_name: "p_session_token",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
|
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
deletePeersBatch as olmDeletePeersBatch
|
deletePeersBatch as olmDeletePeersBatch
|
||||||
} from "@server/routers/olm/peers";
|
} from "@server/routers/olm/peers";
|
||||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||||
|
import { sendToClientsBatch } from "#dynamic/routers/ws";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
@@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess(
|
|||||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sitesList.length === 0) {
|
if (sitesList.length === 0 && siteResource.networkId !== null) {
|
||||||
|
// A site resource with a networkId is expected to have at least one
|
||||||
|
// site attached via siteNetworks. Resources with no networkId (e.g.
|
||||||
|
// inference-mode resources, which connect clients directly to the
|
||||||
|
// exit node instead of any site) are expected to have no sites, so
|
||||||
|
// don't warn for those.
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||||
);
|
);
|
||||||
@@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
clientSiteResourcesToRemove,
|
clientSiteResourcesToRemove,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If this resource requires clients to be connected to the exit node
|
||||||
|
// (e.g. an inference resource), re-sync the connect/disconnect state for
|
||||||
|
// every client whose access to it may have changed - both those who
|
||||||
|
// currently have access and those who just lost it.
|
||||||
|
if (siteResource.requiresExitNodeConnection) {
|
||||||
|
await syncClientExitNodeConnections(
|
||||||
|
Array.from(
|
||||||
|
new Set([
|
||||||
|
...mergedAllClientIds,
|
||||||
|
...existingClientSiteResourceIds
|
||||||
|
])
|
||||||
|
),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForSiteClients(
|
async function handleMessagesForSiteClients(
|
||||||
@@ -966,7 +990,7 @@ export async function updateClientSiteDestinations(
|
|||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
for (const site of sitesData) {
|
for (const site of sitesData) {
|
||||||
if (!site.sites.subnet) {
|
if (!site.sites.exitNodeSubnet) {
|
||||||
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1026,7 @@ export async function updateClientSiteDestinations(
|
|||||||
sourcePort: parsedEndpoint.port,
|
sourcePort: parsedEndpoint.port,
|
||||||
destinations: [
|
destinations: [
|
||||||
{
|
{
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1010,7 +1034,7 @@ export async function updateClientSiteDestinations(
|
|||||||
} else {
|
} else {
|
||||||
// add to the existing destinations
|
// add to the existing destinations
|
||||||
destinations.destinations.push({
|
destinations.destinations.push({
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1076,265 @@ export async function updateClientSiteDestinations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determines, for each of the given clients, whether they currently have
|
||||||
|
// access to any enabled site resource with requiresExitNodeConnection set
|
||||||
|
// (e.g. an inference-mode resource) and tells the client's olm to connect to
|
||||||
|
// or disconnect from its assigned exit node accordingly. Site resources with
|
||||||
|
// requiresExitNodeConnection don't belong to any site/network, so this can't
|
||||||
|
// be derived from the per-site peer logic above - it has to be recomputed
|
||||||
|
// from the client's full current resource access every time that access
|
||||||
|
// changes.
|
||||||
|
async function syncClientExitNodeConnections(
|
||||||
|
clientIds: number[],
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
const uniqueClientIds = Array.from(new Set(clientIds));
|
||||||
|
if (uniqueClientIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clients with an exit node assigned can be told to connect/disconnect.
|
||||||
|
const clientsData = await trx
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
exitNodeId: clients.exitNodeId,
|
||||||
|
exitNodeSubnet: clients.exitNodeSubnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(clients.clientId, uniqueClientIds),
|
||||||
|
isNotNull(clients.exitNodeId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientsData.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
|
||||||
|
|
||||||
|
const requiresExitNodeRows = await trx
|
||||||
|
.select({
|
||||||
|
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
fullDomain: siteResources.fullDomain
|
||||||
|
})
|
||||||
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||||
|
siteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
clientIdsWithExitNode
|
||||||
|
),
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.requiresExitNodeConnection, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsConnectSet = new Set(
|
||||||
|
requiresExitNodeRows.map((r) => r.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aliases for every exit-node-backed resource this client can reach, so
|
||||||
|
// the live connect push carries the same alias list the register/reconnect
|
||||||
|
// path (buildSiteConfigurationForOlmClient) would compute.
|
||||||
|
const exitNodeAliasesByClientId = new Map<number, (string | null)[]>();
|
||||||
|
for (const row of requiresExitNodeRows) {
|
||||||
|
if (row.alias == null && row.fullDomain == null) continue;
|
||||||
|
const existing = exitNodeAliasesByClientId.get(row.clientId);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(row.fullDomain || row.alias); // accept both for now in case we have other resource types that dont use the full domain
|
||||||
|
} else {
|
||||||
|
exitNodeAliasesByClientId.set(row.clientId, [
|
||||||
|
row.fullDomain || row.alias
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitNodeIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
clientsData
|
||||||
|
.map((c) => c.exitNodeId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitNodeRows =
|
||||||
|
exitNodeIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
|
||||||
|
: [];
|
||||||
|
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
|
||||||
|
|
||||||
|
const olmRows = await trx
|
||||||
|
.select({
|
||||||
|
clientId: olms.clientId,
|
||||||
|
olmId: olms.olmId,
|
||||||
|
version: olms.version
|
||||||
|
})
|
||||||
|
.from(olms)
|
||||||
|
.where(inArray(olms.clientId, clientIdsWithExitNode));
|
||||||
|
const olmByClientId = new Map(
|
||||||
|
olmRows
|
||||||
|
.filter((r) => r.clientId !== null)
|
||||||
|
.map((r) => [r.clientId as number, r])
|
||||||
|
);
|
||||||
|
|
||||||
|
const relayPort = config.getRawConfig().gerbil.clients_start_port;
|
||||||
|
|
||||||
|
const connectPayloads: {
|
||||||
|
clientId: string;
|
||||||
|
message: { type: string; data: any };
|
||||||
|
options: { compress: boolean; incrementConfigVersion: boolean };
|
||||||
|
}[] = [];
|
||||||
|
const disconnectPayloads: {
|
||||||
|
clientId: string;
|
||||||
|
message: { type: string; data: any };
|
||||||
|
options: { compress: boolean; incrementConfigVersion: boolean };
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const client of clientsData) {
|
||||||
|
const olm = olmByClientId.get(client.clientId);
|
||||||
|
if (!olm) {
|
||||||
|
// No olm registered for this client yet/anymore, nothing to send.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsConnect = needsConnectSet.has(client.clientId);
|
||||||
|
|
||||||
|
if (needsConnect) {
|
||||||
|
const exitNode = client.exitNodeId
|
||||||
|
? exitNodeById.get(client.exitNodeId)
|
||||||
|
: undefined;
|
||||||
|
if (!exitNode || !client.exitNodeSubnet) {
|
||||||
|
logger.warn(
|
||||||
|
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectPayloads.push({
|
||||||
|
clientId: olm.olmId,
|
||||||
|
message: {
|
||||||
|
type: "olm/wg/exitnode/connect",
|
||||||
|
data: {
|
||||||
|
connect: true,
|
||||||
|
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||||
|
relayPort,
|
||||||
|
publicKey: exitNode.publicKey,
|
||||||
|
serverIP: exitNode.address.split("/")[0],
|
||||||
|
tunnelIP: client.exitNodeSubnet.split("/")[0],
|
||||||
|
aliases:
|
||||||
|
exitNodeAliasesByClientId.get(client.clientId) ?? []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(olm.version, "olm"),
|
||||||
|
incrementConfigVersion: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
disconnectPayloads.push({
|
||||||
|
clientId: olm.olmId,
|
||||||
|
message: {
|
||||||
|
type: "olm/wg/exitnode/disconnect",
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(olm.version, "olm"),
|
||||||
|
incrementConfigVersion: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectPayloads.length > 0) {
|
||||||
|
await sendToClientsBatch(connectPayloads).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error sending exit node connect messages:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disconnectPayloads.length > 0) {
|
||||||
|
await sendToClientsBatch(disconnectPayloads).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifies the olms of every given client that the alias of the site resource
|
||||||
|
// they're using an exit node connection for has changed, via the dedicated
|
||||||
|
// exit node data-update message. Unlike syncClientExitNodeConnections, this
|
||||||
|
// doesn't touch connect/disconnect state - it's purely a rename for clients
|
||||||
|
// that are (and remain) connected to the exit node for this resource.
|
||||||
|
async function syncClientExitNodeAliasUpdate(
|
||||||
|
clientIds: number[],
|
||||||
|
oldAlias: string | null,
|
||||||
|
newAlias: string | null,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
const uniqueClientIds = Array.from(new Set(clientIds));
|
||||||
|
if (uniqueClientIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldAliases = oldAlias ? [oldAlias] : [];
|
||||||
|
const newAliases = newAlias ? [newAlias] : [];
|
||||||
|
if (oldAliases.length === 0 && newAliases.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const olmRows = await trx
|
||||||
|
.select({
|
||||||
|
clientId: olms.clientId,
|
||||||
|
olmId: olms.olmId,
|
||||||
|
version: olms.version
|
||||||
|
})
|
||||||
|
.from(olms)
|
||||||
|
.where(inArray(olms.clientId, uniqueClientIds));
|
||||||
|
|
||||||
|
const updatePayloads = olmRows
|
||||||
|
.filter((r) => r.clientId !== null)
|
||||||
|
.map((olm) => ({
|
||||||
|
clientId: olm.olmId,
|
||||||
|
message: {
|
||||||
|
type: "olm/wg/exitnode/data/update",
|
||||||
|
data: {
|
||||||
|
oldAliases,
|
||||||
|
newAliases
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(olm.version, "olm"),
|
||||||
|
incrementConfigVersion: true // this is important information we would need to sync
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (updatePayloads.length > 0) {
|
||||||
|
await sendToClientsBatch(updatePayloads).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error sending exit node alias update messages:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubnetProxyTargetUpdates(
|
async function handleSubnetProxyTargetUpdates(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
sitesList: Site[],
|
sitesList: Site[],
|
||||||
@@ -1282,7 +1565,7 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
|
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
|
||||||
);
|
);
|
||||||
|
|
||||||
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED
|
// !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!!
|
||||||
const mergedAllClients = await trx
|
const mergedAllClients = await trx
|
||||||
.select({
|
.select({
|
||||||
clientId: clientSiteResourcesAssociationsCache.clientId,
|
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||||
@@ -1709,6 +1992,38 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For a resource that stays on an exit node connection across the update,
|
||||||
|
// the alias is the only field that affects already-connected clients (the
|
||||||
|
// exit node itself, its endpoint, etc. are not per-resource). Tell those
|
||||||
|
// clients' olms about the rename directly via the exit node data-update
|
||||||
|
// message rather than a full connect/disconnect cycle.
|
||||||
|
if (
|
||||||
|
existingSiteResource?.requiresExitNodeConnection &&
|
||||||
|
updatedSiteResource.requiresExitNodeConnection &&
|
||||||
|
aliasChanged
|
||||||
|
) {
|
||||||
|
await syncClientExitNodeAliasUpdate(
|
||||||
|
mergedAllClients.map((c) => c.clientId),
|
||||||
|
existingSiteResource.alias,
|
||||||
|
updatedSiteResource.alias,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this resource requires (or required) clients to be connected to the
|
||||||
|
// exit node (e.g. an inference resource), re-sync connect/disconnect
|
||||||
|
// state for every client currently associated with it - covers toggling
|
||||||
|
// requiresExitNodeConnection on update as well as enabling/disabling it.
|
||||||
|
if (
|
||||||
|
updatedSiteResource.requiresExitNodeConnection ||
|
||||||
|
existingSiteResource?.requiresExitNodeConnection
|
||||||
|
) {
|
||||||
|
await syncClientExitNodeConnections(
|
||||||
|
mergedAllClients.map((c) => c.clientId),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
|
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
|
||||||
);
|
);
|
||||||
@@ -1990,6 +2305,10 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
resourcesToRemove,
|
resourcesToRemove,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Re-sync exit node connect/disconnect state based on this client's
|
||||||
|
// current full set of resource access (e.g. inference resources).
|
||||||
|
await syncClientExitNodeConnections([client.clientId], trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientSites(
|
async function handleMessagesForClientSites(
|
||||||
|
|||||||
@@ -516,6 +516,11 @@ export class TraefikConfigManager {
|
|||||||
const maintenanceHost =
|
const maintenanceHost =
|
||||||
config.getRawConfig().server.internal_hostname;
|
config.getRawConfig().server.internal_hostname;
|
||||||
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
|
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
|
||||||
|
const aiGatewayUrl =
|
||||||
|
config.getRawConfig().server.ai_gateway_override ||
|
||||||
|
`http://${maintenanceHost}:${
|
||||||
|
config.getRawConfig().server.ai_gateway_port
|
||||||
|
}`;
|
||||||
|
|
||||||
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
|
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
|
||||||
traefikConfig = await getTraefikConfig(
|
traefikConfig = await getTraefikConfig(
|
||||||
@@ -528,7 +533,8 @@ export class TraefikConfigManager {
|
|||||||
? false
|
? false
|
||||||
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
|
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
|
||||||
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
|
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
|
||||||
pangolinUIUrl // generate browser gateway targets on cloud and hybrid
|
pangolinUIUrl, // generate browser gateway targets on cloud and hybrid
|
||||||
|
aiGatewayUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { db, targetHealthCheck, domains } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
targetHealthCheck,
|
||||||
|
domains,
|
||||||
|
aiProviders,
|
||||||
|
resourceAiProviders,
|
||||||
|
siteResources,
|
||||||
|
exitNodes
|
||||||
|
} from "@server/db";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -15,6 +23,7 @@ import config from "@server/lib/config";
|
|||||||
import { resources, sites, Target, targets } from "@server/db";
|
import { resources, sites, Target, targets } from "@server/db";
|
||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||||
|
import regionalCache from "@server/lib/cache";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -45,8 +54,24 @@ export async function getTraefikConfig(
|
|||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
|
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
|
||||||
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE
|
browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
|
||||||
|
aiGatewayUrl: string | null = null
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
|
||||||
|
const exitNodeCacheKey = `exitNode:${exitNodeId}`;
|
||||||
|
let exitNode =
|
||||||
|
await regionalCache.get<typeof exitNodes.$inferSelect>(
|
||||||
|
exitNodeCacheKey
|
||||||
|
);
|
||||||
|
if (!exitNode) {
|
||||||
|
[exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
await regionalCache.set(exitNodeCacheKey, exitNode, 300);
|
||||||
|
}
|
||||||
|
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
const resourcesWithTargetsAndSites = await db
|
const resourcesWithTargetsAndSites = await db
|
||||||
@@ -87,7 +112,7 @@ export async function getTraefikConfig(
|
|||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online,
|
siteOnline: sites.online,
|
||||||
subnet: sites.subnet,
|
subnet: sites.exitNodeSubnet,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
// Domain cert resolver fields
|
// Domain cert resolver fields
|
||||||
domainCertResolver: domains.certResolver,
|
domainCertResolver: domains.certResolver,
|
||||||
@@ -209,8 +234,42 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inference-mode resources have no targets/sites (their "backend" is the
|
||||||
|
// central AI gateway), so they can't be reached via the targets->sites
|
||||||
|
// join above - query them separately and include them on every exit node.
|
||||||
|
const inferenceResources = await db
|
||||||
|
.selectDistinct({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
resourceName: resources.name,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
subdomain: resources.subdomain,
|
||||||
|
domainId: resources.domainId,
|
||||||
|
enabled: resources.enabled,
|
||||||
|
wildcard: resources.wildcard,
|
||||||
|
domainCertResolver: domains.certResolver,
|
||||||
|
preferWildcardCert: domains.preferWildcardCert
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
// .innerJoin(
|
||||||
|
// resourceAiProviders,
|
||||||
|
// eq(resources.resourceId, resourceAiProviders.resourceId)
|
||||||
|
// )
|
||||||
|
// .innerJoin(
|
||||||
|
// aiProviders,
|
||||||
|
// eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
// )
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.mode, "inference"),
|
||||||
|
eq(resources.enabled, true)
|
||||||
|
// eq(aiProviders.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// make sure we have at least one resource
|
// make sure we have at least one resource
|
||||||
if (resourcesMap.size === 0) {
|
if (resourcesMap.size === 0 && inferenceResources.length === 0) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,5 +732,229 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aiGatewayUrl) {
|
||||||
|
// The AI gateway may live on a different host than the inference
|
||||||
|
// resource itself (e.g. a remote exit node forwarding to the
|
||||||
|
// central dashboard over a tunnel). passHostHeader would forward
|
||||||
|
// the resource's own Host, which that external host won't
|
||||||
|
// recognize, so we pin the Host header to the gateway's own host
|
||||||
|
// and smuggle the original resource host through in "p-host"
|
||||||
|
// instead.
|
||||||
|
let aiGatewayHost: string | undefined;
|
||||||
|
try {
|
||||||
|
aiGatewayHost = new URL(aiGatewayUrl).host;
|
||||||
|
} catch {
|
||||||
|
aiGatewayHost = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public inference resources: same TLS/cert-resolver handling as
|
||||||
|
// plain http-mode resources, but the service points at the AI
|
||||||
|
// gateway instead of any real backend targets.
|
||||||
|
for (const ir of inferenceResources) {
|
||||||
|
if (!ir.enabled) continue;
|
||||||
|
if (!ir.domainId || !ir.fullDomain) continue;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) config_output.http.routers = {};
|
||||||
|
if (!config_output.http.services) config_output.http.services = {};
|
||||||
|
|
||||||
|
const fullDomain = ir.fullDomain;
|
||||||
|
const irKey = `inference-r${ir.resourceId}`;
|
||||||
|
const routerName = `${irKey}-router`;
|
||||||
|
const serviceName = `${irKey}-service`;
|
||||||
|
|
||||||
|
let rule: string;
|
||||||
|
if (ir.wildcard && fullDomain.startsWith("*.")) {
|
||||||
|
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
|
||||||
|
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
|
||||||
|
} else {
|
||||||
|
rule = `Host(\`${fullDomain}\`)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
let wildCard;
|
||||||
|
if (domainParts.length <= 2) {
|
||||||
|
wildCard = `*.${domainParts.join(".")}`;
|
||||||
|
} else {
|
||||||
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
}
|
||||||
|
if (!ir.subdomain) {
|
||||||
|
wildCard = fullDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
const resolverName = ir.domainCertResolver
|
||||||
|
? ir.domainCertResolver.trim()
|
||||||
|
: globalDefaultResolver;
|
||||||
|
const preferWildcard =
|
||||||
|
ir.preferWildcardCert !== undefined &&
|
||||||
|
ir.preferWildcardCert !== null
|
||||||
|
? ir.preferWildcardCert
|
||||||
|
: globalDefaultPreferWildcard;
|
||||||
|
|
||||||
|
const tls = {
|
||||||
|
certResolver: resolverName,
|
||||||
|
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
config_output.http.middlewares[irHeadersMiddlewareName] = {
|
||||||
|
headers: {
|
||||||
|
customRequestHeaders: {
|
||||||
|
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
||||||
|
"p-host": fullDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
const routerMiddlewares = [
|
||||||
|
badgerMiddlewareName,
|
||||||
|
irHeadersMiddlewareName,
|
||||||
|
...additionalMiddlewares
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ir.ssl) {
|
||||||
|
config_output.http.routers[routerName + "-redirect"] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.routers[routerName] = {
|
||||||
|
entryPoints: [
|
||||||
|
ir.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100,
|
||||||
|
...(ir.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services[serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [{ url: aiGatewayUrl }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private (siteResource) inference resources: routed by their alias
|
||||||
|
// instead of a public fullDomain, and deliberately WITHOUT the
|
||||||
|
// badger middleware - no per-user auth/policy stack exists for
|
||||||
|
// siteResources today, so gating here is reachability-only for now.
|
||||||
|
const siteResourcesInference = await db
|
||||||
|
.selectDistinct({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
fullDomain: siteResources.fullDomain,
|
||||||
|
ssl: siteResources.ssl,
|
||||||
|
enabled: siteResources.enabled
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.mode, "inference"),
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
isNotNull(siteResources.fullDomain)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const sr of siteResourcesInference) {
|
||||||
|
if (!sr.enabled || !sr.fullDomain) continue;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) config_output.http.routers = {};
|
||||||
|
if (!config_output.http.services) config_output.http.services = {};
|
||||||
|
|
||||||
|
const fullDomain = sr.fullDomain;
|
||||||
|
const srKey = `inference-sr${sr.siteResourceId}`;
|
||||||
|
const routerName = `${srKey}-router`;
|
||||||
|
const serviceName = `${srKey}-service`;
|
||||||
|
const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to
|
||||||
|
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
const wildCard =
|
||||||
|
domainParts.length <= 2
|
||||||
|
? `*.${domainParts.join(".")}`
|
||||||
|
: `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
const tls = {
|
||||||
|
certResolver: globalDefaultResolver,
|
||||||
|
...(globalDefaultPreferWildcard
|
||||||
|
? { domains: [{ main: wildCard }] }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
config_output.http.middlewares[srHeadersMiddlewareName] = {
|
||||||
|
headers: {
|
||||||
|
customRequestHeaders: {
|
||||||
|
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
||||||
|
"p-host": fullDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
const routerMiddlewares = [
|
||||||
|
srHeadersMiddlewareName,
|
||||||
|
...additionalMiddlewares
|
||||||
|
];
|
||||||
|
|
||||||
|
if (sr.ssl) {
|
||||||
|
config_output.http.routers[routerName + "-redirect"] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.routers[routerName] = {
|
||||||
|
entryPoints: [
|
||||||
|
sr.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100,
|
||||||
|
...(sr.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services[serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [{ url: aiGatewayUrl }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config_output;
|
return config_output;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export * from "./verifyUserHasAction";
|
|||||||
export * from "./verifyApiKeyAccess";
|
export * from "./verifyApiKeyAccess";
|
||||||
export * from "./verifySiteProvisioningKeyAccess";
|
export * from "./verifySiteProvisioningKeyAccess";
|
||||||
export * from "./verifyDomainAccess";
|
export * from "./verifyDomainAccess";
|
||||||
|
export * from "./verifyAiProviderAccess";
|
||||||
|
export * from "./verifyAiModelAccess";
|
||||||
export * from "./verifyUserIsOrgOwner";
|
export * from "./verifyUserIsOrgOwner";
|
||||||
export * from "./verifyUserFromResourceSession";
|
export * from "./verifyUserFromResourceSession";
|
||||||
export * from "./verifySiteResourceAccess";
|
export * from "./verifySiteResourceAccess";
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ export * from "./verifyApiKeyClientAccess";
|
|||||||
export * from "./verifyApiKeySiteResourceAccess";
|
export * from "./verifyApiKeySiteResourceAccess";
|
||||||
export * from "./verifyApiKeyIdpAccess";
|
export * from "./verifyApiKeyIdpAccess";
|
||||||
export * from "./verifyApiKeyDomainAccess";
|
export * from "./verifyApiKeyDomainAccess";
|
||||||
|
export * from "./verifyApiKeyAiProviderAccess";
|
||||||
|
export * from "./verifyApiKeyAiModelAccess";
|
||||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||||
export * from "./verifyApiKeySiteProvisioningKeyAccess";
|
export * from "./verifyApiKeySiteProvisioningKeyAccess";
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
|
export async function verifyApiKeyAiModelAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const modelIdRaw = getFirstString(req.params.modelId);
|
||||||
|
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(modelId)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
model: aiModels,
|
||||||
|
provider: aiProviders
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(aiModels.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(eq(aiModels.modelId, modelId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI model with ID ${modelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey.isRoot) {
|
||||||
|
req.aiProvider = row.provider;
|
||||||
|
req.aiModel = row.model;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = row.provider.orgId;
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||||
|
const apiKeyOrgRes = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.aiProvider = row.provider;
|
||||||
|
req.aiModel = row.model;
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying AI model access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { aiProviders, apiKeyOrg, db } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
|
export async function verifyApiKeyAiProviderAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const providerIdRaw = getFirstString(req.params.providerId);
|
||||||
|
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(providerId)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [provider] = await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey.isRoot) {
|
||||||
|
req.aiProvider = provider;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = provider.orgId;
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||||
|
const apiKeyOrgRes = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.aiProvider = provider;
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying AI provider access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources, targets, apiKeyOrg } from "@server/db";
|
import { aiProviders, resources, targets, apiKeyOrg } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -43,43 +43,65 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceId = target.resourceId;
|
const { resourceId, providerId } = target;
|
||||||
if (!resourceId) {
|
if ((!resourceId && !providerId) || (resourceId && providerId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
`Target with ID ${targetId} does not have a resource ID`
|
`Target with ID ${targetId} has invalid ownership`
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [resource] = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.resourceId, resourceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found`
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey.isRoot) {
|
if (apiKey.isRoot) {
|
||||||
// Root keys can access any key in any org
|
// Root keys can access any target
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.orgId) {
|
let orgId: string;
|
||||||
return next(
|
if (resourceId) {
|
||||||
createHttpError(
|
const [resource] = await db
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
.select()
|
||||||
`Resource with ID ${resourceId} does not have an organization ID`
|
.from(resources)
|
||||||
)
|
.where(eq(resources.resourceId, resourceId))
|
||||||
);
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Resource with ID ${resourceId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = resource.orgId;
|
||||||
|
} else {
|
||||||
|
const [provider] = await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId!))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = provider.orgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
if (!req.apiKeyOrg) {
|
||||||
@@ -89,7 +111,7 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
eq(apiKeyOrg.orgId, resource.orgId)
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -98,7 +120,7 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { aiModels, aiProviders, db, userOrgs } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
|
export async function verifyAiModelAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const modelIdRaw = getFirstString(req.params.modelId);
|
||||||
|
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(modelId)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
model: aiModels,
|
||||||
|
provider: aiProviders
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(aiModels.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(eq(aiModels.modelId, modelId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI model with ID ${modelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = row.provider.orgId;
|
||||||
|
|
||||||
|
if (!req.userOrg || req.userOrg.orgId !== orgId) {
|
||||||
|
const userOrgRole = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
req.userOrg = userOrgRole[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
|
||||||
|
const policyCheck = await checkOrgAccessPolicy({
|
||||||
|
orgId: req.userOrg.orgId,
|
||||||
|
userId,
|
||||||
|
session: req.session
|
||||||
|
});
|
||||||
|
req.orgPolicyAllowed = policyCheck.allowed;
|
||||||
|
if (!policyCheck.allowed || policyCheck.error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"" + (policyCheck.error || "Unknown error")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userOrgId = orgId;
|
||||||
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
|
req.aiProvider = row.provider;
|
||||||
|
req.aiModel = row.model;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying AI model access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { aiProviders, db, userOrgs } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
|
export async function verifyAiProviderAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const providerIdRaw = getFirstString(req.params.providerId);
|
||||||
|
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(providerId)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [provider] = await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = provider.orgId;
|
||||||
|
|
||||||
|
if (!req.userOrg || req.userOrg.orgId !== orgId) {
|
||||||
|
const userOrgRole = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
req.userOrg = userOrgRole[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
|
||||||
|
const policyCheck = await checkOrgAccessPolicy({
|
||||||
|
orgId: req.userOrg.orgId,
|
||||||
|
userId,
|
||||||
|
session: req.session
|
||||||
|
});
|
||||||
|
req.orgPolicyAllowed = policyCheck.allowed;
|
||||||
|
if (!policyCheck.allowed || policyCheck.error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"" + (policyCheck.error || "Unknown error")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userOrgId = orgId;
|
||||||
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
|
req.aiProvider = provider;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying AI provider access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources, targets, userOrgs } from "@server/db";
|
import { aiProviders, resources, targets, userOrgs } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -25,9 +25,7 @@ export async function verifyTargetAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(targetId)) {
|
if (isNaN(targetId)) {
|
||||||
return next(
|
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID"));
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await db
|
const target = await db
|
||||||
@@ -45,73 +43,88 @@ export async function verifyTargetAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceId = target[0].resourceId;
|
const { resourceId, providerId } = target[0];
|
||||||
|
|
||||||
if (!resourceId) {
|
if ((!resourceId && !providerId) || (resourceId && providerId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
`Target with ID ${targetId} does not have a resource ID`
|
`Target with ID ${targetId} has invalid ownership`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resource = await db
|
let orgId: string;
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.resourceId, resourceId!))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (resource.length === 0) {
|
if (resourceId) {
|
||||||
return next(
|
const [resource] = await db
|
||||||
createHttpError(
|
.select()
|
||||||
HttpCode.NOT_FOUND,
|
.from(resources)
|
||||||
`Resource with ID ${resourceId} not found`
|
.where(eq(resources.resourceId, resourceId))
|
||||||
)
|
.limit(1);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resource[0].orgId) {
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.NOT_FOUND,
|
||||||
`resource with ID ${resourceId} does not have an organization ID`
|
`Resource with ID ${resourceId} not found`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Resource with ID ${resourceId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = resource.orgId;
|
||||||
|
} else {
|
||||||
|
const [provider] = await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId!))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId = provider.orgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
const res = await db
|
const userOrgResult = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
eq(userOrgs.orgId, resource[0].orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
req.userOrg = res[0];
|
req.userOrg = userOrgResult[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg || req.userOrg.orgId !== orgId) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"User does not have access to this organization"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
|
||||||
req.userOrg.userId,
|
|
||||||
resource[0].orgId!
|
|
||||||
);
|
|
||||||
req.userOrgId = resource[0].orgId!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrg.orgId;
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
|
req.userOrgId = orgId;
|
||||||
|
|
||||||
if (req.orgPolicyAllowed === undefined && orgId) {
|
if (req.orgPolicyAllowed === undefined) {
|
||||||
const policyCheck = await checkOrgAccessPolicy({
|
const policyCheck = await checkOrgAccessPolicy({
|
||||||
orgId,
|
orgId,
|
||||||
userId,
|
userId,
|
||||||
@@ -128,22 +141,24 @@ export async function verifyTargetAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceAllowed = await canUserAccessResource({
|
if (resourceId) {
|
||||||
userId,
|
const resourceAllowed = await canUserAccessResource({
|
||||||
resourceId,
|
userId,
|
||||||
roleIds: req.userOrgRoleIds ?? []
|
resourceId,
|
||||||
});
|
roleIds: req.userOrgRoleIds ?? []
|
||||||
|
});
|
||||||
|
|
||||||
if (!resourceAllowed) {
|
if (!resourceAllowed) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this resource"
|
"User does not have access to this resource"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
return next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
+3
-1
@@ -28,7 +28,9 @@ export enum OpenAPITags {
|
|||||||
HealthCheck = "Health Check",
|
HealthCheck = "Health Check",
|
||||||
PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)",
|
PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)",
|
||||||
PublicResourceLegacy = "Public Resource (Legacy)",
|
PublicResourceLegacy = "Public Resource (Legacy)",
|
||||||
PrivateResourceLegacy = "Private Resource (Legacy)"
|
PrivateResourceLegacy = "Private Resource (Legacy)",
|
||||||
|
AiProvider = "AI Provider",
|
||||||
|
AiModel = "AI Model"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order here controls the order tags are displayed in Swagger UI
|
// Order here controls the order tags are displayed in Swagger UI
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
Transaction
|
Transaction
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ExitNodePingResult } from "@server/routers/newt";
|
|
||||||
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import config from "../config";
|
import config from "../config";
|
||||||
@@ -330,6 +329,16 @@ export async function listExitNodes(
|
|||||||
return exitNodesList;
|
return exitNodesList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExitNodePingResult = {
|
||||||
|
exitNodeId: number;
|
||||||
|
latencyMs: number;
|
||||||
|
weight: number;
|
||||||
|
error?: string;
|
||||||
|
exitNodeName: string;
|
||||||
|
endpoint: string;
|
||||||
|
wasPreviouslyConnected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the most suitable exit node from a list of ping results.
|
* Selects the most suitable exit node from a list of ping results.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
loginPage,
|
loginPage,
|
||||||
|
SiteResource,
|
||||||
targetHealthCheck
|
targetHealthCheck
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +41,10 @@ import {
|
|||||||
siteNetworks,
|
siteNetworks,
|
||||||
siteResources,
|
siteResources,
|
||||||
Target,
|
Target,
|
||||||
targets
|
targets,
|
||||||
|
aiProviders,
|
||||||
|
resourceAiProviders,
|
||||||
|
siteResourceAiProviders
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import {
|
import {
|
||||||
sanitize,
|
sanitize,
|
||||||
@@ -54,6 +58,7 @@ import {
|
|||||||
getValidCertificatesForDomains
|
getValidCertificatesForDomains
|
||||||
} from "#private/lib/certificates";
|
} from "#private/lib/certificates";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import regionalCache from "#private/lib/cache";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||||
@@ -87,8 +92,24 @@ export async function getTraefikConfig(
|
|||||||
generateLoginPageRouters = false,
|
generateLoginPageRouters = false,
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
maintenancePageUiUrl: string | null = null,
|
maintenancePageUiUrl: string | null = null,
|
||||||
browserGatewayUiUrl: string | null = null
|
browserGatewayUiUrl: string | null = null,
|
||||||
|
aiGatewayUrl: string | null = null
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
|
||||||
|
const exitNodeCacheKey = `exitNode:${exitNodeId}`;
|
||||||
|
let exitNode =
|
||||||
|
await regionalCache.get<typeof exitNodes.$inferSelect>(
|
||||||
|
exitNodeCacheKey
|
||||||
|
);
|
||||||
|
if (!exitNode) {
|
||||||
|
[exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
await regionalCache.set(exitNodeCacheKey, exitNode, 300);
|
||||||
|
}
|
||||||
|
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
const resourcesWithTargetsAndSites = await db
|
const resourcesWithTargetsAndSites = await db
|
||||||
@@ -136,7 +157,7 @@ export async function getTraefikConfig(
|
|||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online,
|
siteOnline: sites.online,
|
||||||
subnet: sites.subnet,
|
subnet: sites.exitNodeSubnet,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
// Namespace
|
// Namespace
|
||||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||||
@@ -361,7 +382,7 @@ export async function getTraefikConfig(
|
|||||||
let siteResourcesWithFullDomain: {
|
let siteResourcesWithFullDomain: {
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
mode: "http" | "host" | "cidr" | "ssh";
|
mode: SiteResource["mode"];
|
||||||
}[] = [];
|
}[] = [];
|
||||||
if (
|
if (
|
||||||
build == "enterprise" &&
|
build == "enterprise" &&
|
||||||
@@ -386,7 +407,7 @@ export async function getTraefikConfig(
|
|||||||
and(
|
and(
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
isNotNull(siteResources.fullDomain),
|
isNotNull(siteResources.fullDomain),
|
||||||
eq(siteResources.mode, "http"),
|
eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below
|
||||||
eq(siteResources.ssl, true),
|
eq(siteResources.ssl, true),
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
inArray(sites.type, siteTypes)
|
inArray(sites.type, siteTypes)
|
||||||
@@ -394,6 +415,56 @@ export async function getTraefikConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inference-mode resources/siteResources have no targets/sites/network
|
||||||
|
// (their "backend" is the central AI gateway, not something on a site),
|
||||||
|
// so they can't be reached via the joins above - query them separately
|
||||||
|
// and include them on every exit node.
|
||||||
|
const inferenceResources = await db
|
||||||
|
.selectDistinct({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
subdomain: resources.subdomain,
|
||||||
|
domainId: resources.domainId,
|
||||||
|
enabled: resources.enabled,
|
||||||
|
wildcard: resources.wildcard,
|
||||||
|
domainCertResolver: domains.certResolver,
|
||||||
|
preferWildcardCert: domains.preferWildcardCert
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.innerJoin(
|
||||||
|
resourceAiProviders,
|
||||||
|
eq(resources.resourceId, resourceAiProviders.resourceId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.mode, "inference"),
|
||||||
|
eq(resources.enabled, true),
|
||||||
|
eq(aiProviders.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteResourcesInference = await db
|
||||||
|
.selectDistinct({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
fullDomain: siteResources.fullDomain,
|
||||||
|
ssl: siteResources.ssl,
|
||||||
|
enabled: siteResources.enabled
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.mode, "inference"),
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
isNotNull(siteResources.fullDomain)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
@@ -415,6 +486,17 @@ export async function getTraefikConfig(
|
|||||||
domains.add(bgResource.fullDomain);
|
domains.add(bgResource.fullDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include inference resource/siteResource domains
|
||||||
|
for (const ir of inferenceResources) {
|
||||||
|
if (ir.enabled && ir.ssl && ir.fullDomain) {
|
||||||
|
domains.add(ir.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sr of siteResourcesInference) {
|
||||||
|
if (sr.enabled && sr.ssl && sr.fullDomain) {
|
||||||
|
domains.add(sr.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
// get the valid certs for these domains
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
@@ -1461,6 +1543,244 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aiGatewayUrl) {
|
||||||
|
// The AI gateway may live on a different host than the inference
|
||||||
|
// resource itself (e.g. a remote exit node forwarding to the
|
||||||
|
// central dashboard over a tunnel). passHostHeader would forward
|
||||||
|
// the resource's own Host, which that external host won't
|
||||||
|
// recognize, so we pin the Host header to the gateway's own host
|
||||||
|
// and smuggle the original resource host through in "p-host"
|
||||||
|
// instead (same pattern as the maintenance-page routes above).
|
||||||
|
let aiGatewayHost: string | undefined;
|
||||||
|
try {
|
||||||
|
aiGatewayHost = new URL(aiGatewayUrl).host;
|
||||||
|
} catch {
|
||||||
|
aiGatewayHost = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The p-host smuggling above is only necessary when the AI gateway
|
||||||
|
// is overridden to a different host than the resource's own. In the
|
||||||
|
// default case, leave the Host header untouched so it's visible on
|
||||||
|
// the other end.
|
||||||
|
const aiGatewayOverride =
|
||||||
|
config.getRawConfig().server.ai_gateway_override;
|
||||||
|
|
||||||
|
// Public inference resources: same TLS/cert-resolver handling as
|
||||||
|
// plain http-mode resources, but the service points at the AI
|
||||||
|
// gateway instead of any real backend targets.
|
||||||
|
for (const ir of inferenceResources) {
|
||||||
|
if (!ir.enabled) continue;
|
||||||
|
if (!ir.domainId || !ir.fullDomain) continue;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) config_output.http.routers = {};
|
||||||
|
if (!config_output.http.services) config_output.http.services = {};
|
||||||
|
|
||||||
|
const fullDomain = ir.fullDomain;
|
||||||
|
const irKey = `inference-r${ir.resourceId}`;
|
||||||
|
const routerName = `${irKey}-router`;
|
||||||
|
const serviceName = `${irKey}-service`;
|
||||||
|
|
||||||
|
let rule: string;
|
||||||
|
if (ir.wildcard && fullDomain.startsWith("*.")) {
|
||||||
|
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
|
||||||
|
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
|
||||||
|
} else {
|
||||||
|
rule = `Host(\`${fullDomain}\`)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tls: any = {};
|
||||||
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
let wildCard;
|
||||||
|
if (domainParts.length <= 2) {
|
||||||
|
wildCard = `*.${domainParts.join(".")}`;
|
||||||
|
} else {
|
||||||
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
}
|
||||||
|
if (!ir.subdomain) {
|
||||||
|
wildCard = fullDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
const resolverName = ir.domainCertResolver
|
||||||
|
? ir.domainCertResolver.trim()
|
||||||
|
: globalDefaultResolver;
|
||||||
|
const preferWildcard =
|
||||||
|
ir.preferWildcardCert !== undefined &&
|
||||||
|
ir.preferWildcardCert !== null
|
||||||
|
? ir.preferWildcardCert
|
||||||
|
: globalDefaultPreferWildcard;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: resolverName,
|
||||||
|
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for inference resource domain: ${fullDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
const routerMiddlewares = [badgerMiddlewareName];
|
||||||
|
|
||||||
|
if (aiGatewayOverride) {
|
||||||
|
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
|
||||||
|
config_output.http.middlewares[irHeadersMiddlewareName] = {
|
||||||
|
headers: {
|
||||||
|
customRequestHeaders: {
|
||||||
|
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
||||||
|
"p-host": fullDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
routerMiddlewares.push(irHeadersMiddlewareName);
|
||||||
|
}
|
||||||
|
|
||||||
|
routerMiddlewares.push(...additionalMiddlewares);
|
||||||
|
|
||||||
|
if (ir.ssl) {
|
||||||
|
config_output.http.routers[routerName + "-redirect"] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.routers[routerName] = {
|
||||||
|
entryPoints: [
|
||||||
|
ir.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100,
|
||||||
|
...(ir.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services[serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [{ url: aiGatewayUrl }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private (siteResource) inference resources: routed by their alias
|
||||||
|
// instead of a public fullDomain, and deliberately WITHOUT the
|
||||||
|
// badger middleware - no per-user auth/policy stack exists for
|
||||||
|
// siteResources today (see plan doc), so gating here is
|
||||||
|
// reachability-only for now.
|
||||||
|
for (const sr of siteResourcesInference) {
|
||||||
|
if (!sr.enabled || !sr.fullDomain) continue;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) config_output.http.routers = {};
|
||||||
|
if (!config_output.http.services) config_output.http.services = {};
|
||||||
|
|
||||||
|
const fullDomain = sr.fullDomain;
|
||||||
|
const srKey = `inference-sr${sr.siteResourceId}`;
|
||||||
|
const routerName = `${srKey}-router`;
|
||||||
|
const serviceName = `${srKey}-service`;
|
||||||
|
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
|
||||||
|
|
||||||
|
let tls: any = {};
|
||||||
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
const wildCard =
|
||||||
|
domainParts.length <= 2
|
||||||
|
? `*.${domainParts.join(".")}`
|
||||||
|
: `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: globalDefaultResolver,
|
||||||
|
...(globalDefaultPreferWildcard
|
||||||
|
? { domains: [{ main: wildCard }] }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for inference siteResource fullDomain: ${fullDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
const routerMiddlewares: string[] = [];
|
||||||
|
|
||||||
|
if (aiGatewayOverride) {
|
||||||
|
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
|
||||||
|
config_output.http.middlewares[srHeadersMiddlewareName] = {
|
||||||
|
headers: {
|
||||||
|
customRequestHeaders: {
|
||||||
|
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
||||||
|
"p-host": fullDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
routerMiddlewares.push(srHeadersMiddlewareName);
|
||||||
|
}
|
||||||
|
|
||||||
|
routerMiddlewares.push(...additionalMiddlewares);
|
||||||
|
|
||||||
|
if (sr.ssl) {
|
||||||
|
config_output.http.routers[routerName + "-redirect"] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.routers[routerName] = {
|
||||||
|
entryPoints: [
|
||||||
|
sr.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: serviceName,
|
||||||
|
rule,
|
||||||
|
priority: 100,
|
||||||
|
...(sr.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services[serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [{ url: aiGatewayUrl }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (generateLoginPageRouters) {
|
if (generateLoginPageRouters) {
|
||||||
const exitNodeLoginPages = await db
|
const exitNodeLoginPages = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ hybridRouter.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there
|
const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there
|
||||||
|
const aiGatewayUrl = `${config.getRawConfig().app.dashboard_url}/api/v1/ai-gateway`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const traefikConfig = await getTraefikConfig(
|
const traefikConfig = await getTraefikConfig(
|
||||||
@@ -360,7 +361,8 @@ hybridRouter.get(
|
|||||||
false, // Dont include login pages,
|
false, // Dont include login pages,
|
||||||
true, // allow raw resources
|
true, // allow raw resources
|
||||||
pangolinUIUrl, // dont generate maintenance page
|
pangolinUIUrl, // dont generate maintenance page
|
||||||
pangolinUIUrl // generate browser gateway targets
|
pangolinUIUrl, // generate browser gateway targets
|
||||||
|
aiGatewayUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (site.exitNodeId && site.subnet) {
|
if (site.exitNodeId && site.exitNodeSubnet) {
|
||||||
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
|
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
|
||||||
await addPeer(site.exitNodeId, {
|
await addPeer(site.exitNodeId, {
|
||||||
publicKey: pubKey,
|
publicKey: pubKey,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITY_DEFS,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
|
||||||
|
|
||||||
|
export function createAiGatewayRouter() {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||||
|
const capability = def.id as AiCapability;
|
||||||
|
for (const route of def.routes) {
|
||||||
|
router.post(route.path, (req, res) =>
|
||||||
|
handleAiGatewayProxy(req, res, capability)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { handleAiGatewayProxy } from "./pipeline";
|
||||||
|
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
AiProvider,
|
||||||
|
aiModels,
|
||||||
|
aiProviders,
|
||||||
|
clients,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
resourceAiModels,
|
||||||
|
resourceAiProviders,
|
||||||
|
resources,
|
||||||
|
siteResourceAiModels,
|
||||||
|
siteResourceAiProviders,
|
||||||
|
siteResources,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import {
|
||||||
|
AiProviderAuthType,
|
||||||
|
AiProviderType,
|
||||||
|
applyAiProviderAuthHeaders,
|
||||||
|
applyAiProviderCustomHeaders,
|
||||||
|
authTypeRequiresApiKey
|
||||||
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITY_DEFS,
|
||||||
|
providerHasCapability,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import { proxyAiGatewayToSiteTarget } from "@server/routers/aiGateway/targetRouting";
|
||||||
|
import {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
validateSessionToken
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||||
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
|
import { localCache } from "@server/lib/cache";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import {
|
||||||
|
resolveEffectiveLists,
|
||||||
|
type AccessMode,
|
||||||
|
type ModelListType
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
import {
|
||||||
|
compareModelKeySpecificity,
|
||||||
|
isAllowedByLists,
|
||||||
|
mostSpecificMatchingAllow
|
||||||
|
} from "@server/lib/aiModelKeyMatch";
|
||||||
|
import { aiGatewayUpstreamFetch } from "@server/lib/aiGatewayUpstreamFetch";
|
||||||
|
import { getModelPricing, calculateAiCost } from "@server/lib/aiModelPricing";
|
||||||
|
import {
|
||||||
|
extractUsage,
|
||||||
|
estimateUsage,
|
||||||
|
isUsageEmpty,
|
||||||
|
needsStreamUsageInjection,
|
||||||
|
withStreamUsageOption,
|
||||||
|
stripInjectedUsageFrame,
|
||||||
|
extractResponseModel,
|
||||||
|
type AiUsage
|
||||||
|
} from "@server/lib/aiUsageExtraction";
|
||||||
|
|
||||||
|
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
|
||||||
|
const EXIT_NODE_RANGES_TTL_SEC = 6000;
|
||||||
|
const CLIENT_BY_IP_TTL_SEC = 30;
|
||||||
|
const REQUEST_USER_TTL_SEC = 30;
|
||||||
|
|
||||||
|
type CachedClient = { clientId: number; userId: string | null } | null;
|
||||||
|
|
||||||
|
async function getExitNodeRanges(): Promise<string[]> {
|
||||||
|
const cached = localCache.get<string[]>(EXIT_NODE_RANGES_CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ address: exitNodes.address })
|
||||||
|
.from(exitNodes);
|
||||||
|
const ranges = rows.map((r) => r.address);
|
||||||
|
|
||||||
|
localCache.set(
|
||||||
|
EXIT_NODE_RANGES_CACHE_KEY,
|
||||||
|
ranges,
|
||||||
|
EXIT_NODE_RANGES_TTL_SEC
|
||||||
|
);
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findClientByIp(ip: string): Promise<CachedClient> {
|
||||||
|
const cacheKey = `aiGateway:clientByIp:${ip}`;
|
||||||
|
const cached = localCache.get<CachedClient>(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select({ clientId: clients.clientId, userId: clients.userId })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.exitNodeSubnet, `${ip}/32`))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const result: CachedClient = client || null;
|
||||||
|
localCache.set(cacheKey, result, CLIENT_BY_IP_TTL_SEC);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderAttachment = {
|
||||||
|
provider: AiProvider;
|
||||||
|
accessMode: AccessMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceModelPattern = {
|
||||||
|
providerId: number;
|
||||||
|
modelKey: string;
|
||||||
|
listType: ModelListType;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderPatternLists = {
|
||||||
|
allows: string[];
|
||||||
|
blocks: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedTarget = {
|
||||||
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
|
orgId: string | null;
|
||||||
|
attachments: ProviderAttachment[];
|
||||||
|
resourceListsByProvider: Map<number, ProviderPatternLists>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderSelection =
|
||||||
|
| { ok: true; provider: AiProvider }
|
||||||
|
| { ok: false; status: number; message: string };
|
||||||
|
|
||||||
|
export type RequestUser = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Identity headers forwarded to the upstream inference endpoint when the
|
||||||
|
// requesting user is known. Omitted entirely (not sent empty) when we
|
||||||
|
// couldn't resolve a user for the request.
|
||||||
|
export function applyRequestUserHeaders(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
requestUser: RequestUser | null
|
||||||
|
): void {
|
||||||
|
if (!requestUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
headers["Remote-User"] = requestUser.username;
|
||||||
|
if (requestUser.email) {
|
||||||
|
headers["Remote-Email"] = requestUser.email;
|
||||||
|
}
|
||||||
|
if (requestUser.name) {
|
||||||
|
headers["Remote-Name"] = requestUser.name;
|
||||||
|
}
|
||||||
|
if (requestUser.role) {
|
||||||
|
headers["Remote-Role"] = requestUser.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRequestUser(
|
||||||
|
userId: string,
|
||||||
|
orgId: string | null
|
||||||
|
): Promise<RequestUser | null> {
|
||||||
|
const cacheKey = `aiGateway:requestUser:${userId}:${orgId || ""}`;
|
||||||
|
const cached = localCache.get<RequestUser | null>(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
localCache.set(cacheKey, null, REQUEST_USER_TTL_SEC);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : [];
|
||||||
|
|
||||||
|
const requestUser: RequestUser = {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: orgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
};
|
||||||
|
|
||||||
|
localCache.set(cacheKey, requestUser, REQUEST_USER_TTL_SEC);
|
||||||
|
return requestUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRequestUser(
|
||||||
|
req: Request,
|
||||||
|
_resourceId: number | null,
|
||||||
|
orgId: string | null
|
||||||
|
): Promise<RequestUser | null> {
|
||||||
|
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
||||||
|
if (sessionToken) {
|
||||||
|
const { session, user } = await validateSessionToken(sessionToken);
|
||||||
|
if (session && user) {
|
||||||
|
return buildRequestUser(user.userId, orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE
|
||||||
|
|
||||||
|
const ip = req.ip;
|
||||||
|
if (!ip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitNodeRanges = await getExitNodeRanges();
|
||||||
|
const inExitNodeRange = exitNodeRanges.some((range) =>
|
||||||
|
isIpInCidr(ip, range)
|
||||||
|
);
|
||||||
|
if (!inExitNodeRange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await findClientByIp(ip);
|
||||||
|
if (!client || !client.userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildRequestUser(client.userId, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||||
|
const [[resourceRow], [siteResourceRow]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
orgId: resources.orgId
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.fullDomain, host),
|
||||||
|
eq(resources.mode, "inference"),
|
||||||
|
eq(resources.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
orgId: siteResources.orgId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.fullDomain, host),
|
||||||
|
eq(siteResources.mode, "inference"),
|
||||||
|
eq(siteResources.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Prefer public inference resources when both match the same host.
|
||||||
|
if (resourceRow) {
|
||||||
|
const [attachmentRows, resourcePatterns] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
provider: aiProviders,
|
||||||
|
accessMode: resourceAiProviders.accessMode
|
||||||
|
})
|
||||||
|
.from(resourceAiProviders)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
resourceAiProviders.resourceId,
|
||||||
|
resourceRow.resourceId
|
||||||
|
),
|
||||||
|
eq(aiProviders.enabled, true),
|
||||||
|
eq(resourceAiProviders.enabled, true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
listType: resourceAiModels.listType,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId))
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (attachmentRows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceId: resourceRow.resourceId,
|
||||||
|
siteResourceId: null,
|
||||||
|
orgId: resourceRow.orgId,
|
||||||
|
attachments: attachmentRows.map((a) => ({
|
||||||
|
provider: a.provider,
|
||||||
|
accessMode: a.accessMode
|
||||||
|
})),
|
||||||
|
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceRow) {
|
||||||
|
const [attachmentRows, resourcePatterns] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
provider: aiProviders,
|
||||||
|
accessMode: siteResourceAiProviders.accessMode
|
||||||
|
})
|
||||||
|
.from(siteResourceAiProviders)
|
||||||
|
.innerJoin(
|
||||||
|
aiProviders,
|
||||||
|
eq(
|
||||||
|
siteResourceAiProviders.providerId,
|
||||||
|
aiProviders.providerId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResourceAiProviders.siteResourceId,
|
||||||
|
siteResourceRow.siteResourceId
|
||||||
|
),
|
||||||
|
eq(aiProviders.enabled, true),
|
||||||
|
eq(siteResourceAiProviders.enabled, true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
listType: siteResourceAiModels.listType,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
|
.from(siteResourceAiModels)
|
||||||
|
.innerJoin(
|
||||||
|
aiModels,
|
||||||
|
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteResourceAiModels.siteResourceId,
|
||||||
|
siteResourceRow.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (attachmentRows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceId: null,
|
||||||
|
siteResourceId: siteResourceRow.siteResourceId,
|
||||||
|
orgId: siteResourceRow.orgId,
|
||||||
|
attachments: attachmentRows.map((a) => ({
|
||||||
|
provider: a.provider,
|
||||||
|
accessMode: a.accessMode
|
||||||
|
})),
|
||||||
|
resourceListsByProvider: groupPatternsByProvider(resourcePatterns)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPatternsByProvider(
|
||||||
|
patterns: ResourceModelPattern[]
|
||||||
|
): Map<number, ProviderPatternLists> {
|
||||||
|
const byProvider = new Map<number, ProviderPatternLists>();
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (!pattern.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lists = byProvider.get(pattern.providerId);
|
||||||
|
if (!lists) {
|
||||||
|
lists = { allows: [], blocks: [] };
|
||||||
|
byProvider.set(pattern.providerId, lists);
|
||||||
|
}
|
||||||
|
if (pattern.listType === "allow") {
|
||||||
|
lists.allows.push(pattern.modelKey);
|
||||||
|
} else {
|
||||||
|
lists.blocks.push(pattern.modelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProvider(
|
||||||
|
attachments: ProviderAttachment[],
|
||||||
|
resourceListsByProvider: Map<number, ProviderPatternLists>,
|
||||||
|
requestedModel: string | undefined
|
||||||
|
): Promise<ProviderSelection> {
|
||||||
|
if (!requestedModel) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: HttpCode.FORBIDDEN,
|
||||||
|
message: "A model must be specified for this resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentByProviderId = new Map(
|
||||||
|
attachments.map((a) => [a.provider.providerId, a])
|
||||||
|
);
|
||||||
|
const providerIds = [...attachmentByProviderId.keys()];
|
||||||
|
if (providerIds.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: HttpCode.FORBIDDEN,
|
||||||
|
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerModels = await db
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
listType: aiModels.listType,
|
||||||
|
enabled: aiModels.enabled
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(inArray(aiModels.providerId, providerIds));
|
||||||
|
|
||||||
|
const allowsByProvider = new Map<number, string[]>();
|
||||||
|
const blocksByProvider = new Map<number, string[]>();
|
||||||
|
for (const model of providerModels) {
|
||||||
|
if (!model.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetMap =
|
||||||
|
model.listType === "allow" ? allowsByProvider : blocksByProvider;
|
||||||
|
const existing = targetMap.get(model.providerId) ?? [];
|
||||||
|
existing.push(model.modelKey);
|
||||||
|
targetMap.set(model.providerId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelCandidate = {
|
||||||
|
provider: AiProvider;
|
||||||
|
modelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidates: ModelCandidate[] = [];
|
||||||
|
for (const [providerId, attachment] of attachmentByProviderId) {
|
||||||
|
const resourceLists = resourceListsByProvider.get(providerId);
|
||||||
|
const { allows, blocks } = resolveEffectiveLists({
|
||||||
|
accessMode: attachment.accessMode,
|
||||||
|
providerAllows: allowsByProvider.get(providerId) ?? [],
|
||||||
|
providerBlocks: blocksByProvider.get(providerId) ?? [],
|
||||||
|
resourceAllows: resourceLists?.allows ?? [],
|
||||||
|
resourceBlocks: resourceLists?.blocks ?? []
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAllowedByLists(requestedModel, allows, blocks)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const matchingAllow = mostSpecificMatchingAllow(requestedModel, allows);
|
||||||
|
if (!matchingAllow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
provider: attachment.provider,
|
||||||
|
modelKey: matchingAllow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: HttpCode.FORBIDDEN,
|
||||||
|
message: `Model "${requestedModel}" is not permitted on this resource`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) =>
|
||||||
|
compareModelKeySpecificity(a.modelKey, b.modelKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const bestSpecificity = candidates[0].modelKey;
|
||||||
|
const topCandidates = candidates.filter(
|
||||||
|
(c) => compareModelKeySpecificity(c.modelKey, bestSpecificity) === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueProviders = new Map<number, AiProvider>();
|
||||||
|
for (const candidate of topCandidates) {
|
||||||
|
uniqueProviders.set(candidate.provider.providerId, candidate.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueProviders.size === 1) {
|
||||||
|
return { ok: true, provider: [...uniqueProviders.values()][0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: HttpCode.FORBIDDEN,
|
||||||
|
message: `Model "${requestedModel}" is ambiguous across multiple AI providers on this resource`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAiUsageAndCost(args: {
|
||||||
|
capability: AiCapability;
|
||||||
|
provider: AiProvider;
|
||||||
|
requestedModel: string | undefined;
|
||||||
|
requestBody: unknown;
|
||||||
|
responseText: string;
|
||||||
|
isStream: boolean;
|
||||||
|
headers: Headers;
|
||||||
|
}): void {
|
||||||
|
const {
|
||||||
|
capability,
|
||||||
|
provider,
|
||||||
|
requestedModel,
|
||||||
|
requestBody,
|
||||||
|
responseText,
|
||||||
|
isStream,
|
||||||
|
headers
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
let usage: AiUsage | null = extractUsage(
|
||||||
|
capability,
|
||||||
|
responseText,
|
||||||
|
isStream,
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
if (!usage || isUsageEmpty(usage)) {
|
||||||
|
usage = estimateUsage(JSON.stringify(requestBody ?? ""), responseText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = extractResponseModel(responseText) ?? requestedModel;
|
||||||
|
const pricing = getModelPricing(provider.type as AiProviderType, model);
|
||||||
|
const cost = calculateAiCost(pricing, usage);
|
||||||
|
|
||||||
|
logger.info("AI gateway request usage", {
|
||||||
|
capability,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
providerType: provider.type,
|
||||||
|
model,
|
||||||
|
estimated: usage.estimated,
|
||||||
|
promptTokens: usage.promptTokens,
|
||||||
|
cacheReadTokens: usage.cacheReadTokens,
|
||||||
|
cacheWriteTokens: usage.cacheWriteTokens,
|
||||||
|
completionTokens: usage.completionTokens,
|
||||||
|
reasoningTokens: usage.reasoningTokens,
|
||||||
|
pricingApproximate: pricing?.approximate ?? null,
|
||||||
|
totalCostUsd: cost?.totalCost ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAiGatewayProxy(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
capability: AiCapability
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const def = AI_CAPABILITY_DEFS[capability];
|
||||||
|
|
||||||
|
const host = (
|
||||||
|
(req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy
|
||||||
|
req.headers.host ||
|
||||||
|
""
|
||||||
|
).split(":")[0];
|
||||||
|
if (!host) {
|
||||||
|
return res
|
||||||
|
.status(HttpCode.BAD_REQUEST)
|
||||||
|
.json({ error: { message: "Missing Host header" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`AI gateway ${capability} request for host: ${host}`);
|
||||||
|
|
||||||
|
const target = await resolveTarget(host);
|
||||||
|
if (!target) {
|
||||||
|
return res.status(HttpCode.NOT_FOUND).json({
|
||||||
|
error: {
|
||||||
|
message: "No inference resource found for this host"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { attachments, resourceListsByProvider, resourceId, orgId } =
|
||||||
|
target;
|
||||||
|
|
||||||
|
const capableAttachments = attachments.filter((a) =>
|
||||||
|
providerHasCapability(a.provider.capabilities, capability)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (capableAttachments.length === 0) {
|
||||||
|
return res.status(HttpCode.FORBIDDEN).json({
|
||||||
|
error: {
|
||||||
|
message: `No AI provider on this resource supports ${capability}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedModel = def.extractModel(req);
|
||||||
|
|
||||||
|
const [requestUser, selection] = await Promise.all([
|
||||||
|
resolveRequestUser(req, resourceId, orgId),
|
||||||
|
selectProvider(
|
||||||
|
capableAttachments,
|
||||||
|
resourceListsByProvider,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (requestUser) {
|
||||||
|
logger.debug(
|
||||||
|
`AI gateway request from user ${requestUser.userId} (${requestUser.username})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.ok) {
|
||||||
|
return res.status(selection.status).json({
|
||||||
|
error: { message: selection.message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { provider } = selection;
|
||||||
|
|
||||||
|
if (provider.type === "custom" && provider.routingMode === "target") {
|
||||||
|
return await proxyAiGatewayToSiteTarget(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
provider,
|
||||||
|
requestUser,
|
||||||
|
capability
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = provider.upstreamUrl;
|
||||||
|
const authType = provider.authType as AiProviderAuthType;
|
||||||
|
|
||||||
|
if (!upstreamUrl) {
|
||||||
|
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
|
error: {
|
||||||
|
message: "AI provider has no upstream URL configured"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiKey: string | null = null;
|
||||||
|
if (authTypeRequiresApiKey(authType)) {
|
||||||
|
if (!provider.apiKey) {
|
||||||
|
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
|
error: {
|
||||||
|
message: "AI provider has no API key configured"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const secret = config.getRawConfig().server.secret!;
|
||||||
|
apiKey = decrypt(provider.apiKey, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = def.resolveUpstreamUrl(
|
||||||
|
upstreamUrl,
|
||||||
|
req,
|
||||||
|
requestedModel!
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipHeaders = new Set([
|
||||||
|
"p-host",
|
||||||
|
"host",
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailers",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"content-length",
|
||||||
|
"accept-encoding"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (skipHeaders.has(key.toLowerCase()) || value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
||||||
|
}
|
||||||
|
applyAiProviderCustomHeaders(
|
||||||
|
headers,
|
||||||
|
provider.headers,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
||||||
|
applyRequestUserHeaders(headers, requestUser);
|
||||||
|
|
||||||
|
// OpenAI's Chat Completions API only reports usage in a streaming
|
||||||
|
// response when asked to via stream_options.include_usage - inject
|
||||||
|
// it ourselves when the caller didn't, so we can still track cost,
|
||||||
|
// and strip the extra frame it adds back out of what we forward.
|
||||||
|
const injectedUsageOurselves = needsStreamUsageInjection(
|
||||||
|
capability,
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
const outboundBody = injectedUsageOurselves
|
||||||
|
? withStreamUsageOption(req.body)
|
||||||
|
: req.body;
|
||||||
|
const body = JSON.stringify(outboundBody);
|
||||||
|
|
||||||
|
logger.debug("AI gateway upstream request", {
|
||||||
|
capability,
|
||||||
|
url: targetUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: outboundBody,
|
||||||
|
skipTlsVerification: provider.skipTlsVerification
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const onClientClose = () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res.on("close", onClientClose);
|
||||||
|
|
||||||
|
let upstreamRes: globalThis.Response;
|
||||||
|
try {
|
||||||
|
upstreamRes = await aiGatewayUpstreamFetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
skipTlsVerification: provider.skipTlsVerification,
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
res.off("close", onClientClose);
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
// Client already disconnected; nothing left to respond to.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({
|
||||||
|
message: "AI gateway upstream fetch failed",
|
||||||
|
url: targetUrl,
|
||||||
|
error: fetchError,
|
||||||
|
cause:
|
||||||
|
fetchError instanceof Error
|
||||||
|
? (fetchError as Error & { cause?: unknown }).cause
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstreamRes.headers.get("content-type") || "";
|
||||||
|
const isStream = def.isStreaming(req, contentType);
|
||||||
|
|
||||||
|
res.status(upstreamRes.status);
|
||||||
|
res.setHeader("Content-Type", contentType || "application/json");
|
||||||
|
|
||||||
|
if (isStream && upstreamRes.body) {
|
||||||
|
res.flushHeaders();
|
||||||
|
const reader = upstreamRes.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = "";
|
||||||
|
// Frame-boundary buffer, only used when we need to filter the
|
||||||
|
// usage-only frame we injected out of what reaches the client.
|
||||||
|
let sseCarry = "";
|
||||||
|
try {
|
||||||
|
while (!abortController.signal.aborted) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunkText = decoder.decode(value, { stream: true });
|
||||||
|
fullText += chunkText;
|
||||||
|
if (injectedUsageOurselves) {
|
||||||
|
sseCarry += chunkText;
|
||||||
|
const lastBoundary = sseCarry.lastIndexOf("\n\n");
|
||||||
|
if (lastBoundary !== -1) {
|
||||||
|
const toEmit = sseCarry.slice(0, lastBoundary + 2);
|
||||||
|
sseCarry = sseCarry.slice(lastBoundary + 2);
|
||||||
|
res.write(stripInjectedUsageFrame(toEmit));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.write(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (injectedUsageOurselves && sseCarry) {
|
||||||
|
res.write(stripInjectedUsageFrame(sseCarry));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
res.off("close", onClientClose);
|
||||||
|
}
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
logAiUsageAndCost({
|
||||||
|
capability,
|
||||||
|
provider,
|
||||||
|
requestedModel,
|
||||||
|
requestBody: req.body,
|
||||||
|
responseText: fullText,
|
||||||
|
isStream: true,
|
||||||
|
headers: upstreamRes.headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.off("close", onClientClose);
|
||||||
|
const text = await upstreamRes.text();
|
||||||
|
logAiUsageAndCost({
|
||||||
|
capability,
|
||||||
|
provider,
|
||||||
|
requestedModel,
|
||||||
|
requestBody: req.body,
|
||||||
|
responseText: text,
|
||||||
|
isStream: false,
|
||||||
|
headers: upstreamRes.headers
|
||||||
|
});
|
||||||
|
return res.send(text);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
|
error: { message: "Failed to proxy inference request" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { AiProvider, db, exitNodes, sites, targets } from "@server/db";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import { localCache } from "@server/lib/cache";
|
||||||
|
import {
|
||||||
|
AiProviderAuthType,
|
||||||
|
applyAiProviderAuthHeaders,
|
||||||
|
applyAiProviderCustomHeaders,
|
||||||
|
authTypeRequiresApiKey
|
||||||
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITY_DEFS,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import {
|
||||||
|
applyRequestUserHeaders,
|
||||||
|
type RequestUser
|
||||||
|
} from "@server/routers/aiGateway/pipeline";
|
||||||
|
|
||||||
|
// Short TTL: long enough to spare the DB on a burst of requests, short
|
||||||
|
// enough that target/site changes (added, removed, exit node moved) show up
|
||||||
|
// almost immediately without needing explicit cache invalidation.
|
||||||
|
const PROVIDER_TARGETS_TTL_SEC = 7;
|
||||||
|
|
||||||
|
// Header gerbil reads to know which scheme://host:port (reachable over the
|
||||||
|
// WireGuard network) to rewrite an incoming /router/* request to. Must
|
||||||
|
// match gerbil's `pangolinDestHeader` constant.
|
||||||
|
const PANGOLIN_DEST_HEADER = "p-dest-header";
|
||||||
|
|
||||||
|
// Header gerbil reads for the Host header value to send to the destination,
|
||||||
|
// when it should differ from PANGOLIN_DEST_HEADER (the target's configured
|
||||||
|
// ip rather than the WireGuard routing address). Must match gerbil's
|
||||||
|
// `pangolinHostHeader` constant.
|
||||||
|
const PANGOLIN_HOST_HEADER = "p-dest-host-header";
|
||||||
|
|
||||||
|
const SKIP_HEADERS = new Set([
|
||||||
|
"p-host",
|
||||||
|
"host",
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailers",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"content-length",
|
||||||
|
"accept-encoding"
|
||||||
|
]);
|
||||||
|
|
||||||
|
type ResolvedProviderTarget = {
|
||||||
|
targetId: number;
|
||||||
|
// "<scheme>://<site exitNodeSubnet host>:<internalPort>", passed to
|
||||||
|
// gerbil as the destination to proxy the request to over the WireGuard
|
||||||
|
// tunnel.
|
||||||
|
destination: string;
|
||||||
|
// The target's configured ip, passed to gerbil as the Host header to
|
||||||
|
// send to the destination (which may differ from the WireGuard routing
|
||||||
|
// address above, e.g. for vhost-based targets).
|
||||||
|
hostHeader: string;
|
||||||
|
// The target's site's exit node HTTP API base URL (gerbil's /router/*).
|
||||||
|
gerbilBaseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchProviderTargets(
|
||||||
|
providerId: number
|
||||||
|
): Promise<ResolvedProviderTarget[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
targetId: targets.targetId,
|
||||||
|
ip: targets.ip,
|
||||||
|
internalPort: targets.internalPort,
|
||||||
|
port: targets.port,
|
||||||
|
method: targets.method,
|
||||||
|
exitNodeSubnet: sites.exitNodeSubnet,
|
||||||
|
reachableAt: exitNodes.reachableAt
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
|
.innerJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||||
|
.where(
|
||||||
|
and(eq(targets.providerId, providerId), eq(targets.enabled, true))
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved: ResolvedProviderTarget[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
// Sites not yet connected to an exit node (no subnet assigned) or
|
||||||
|
// whose exit node has no known HTTP address can't be routed to.
|
||||||
|
if (!row.exitNodeSubnet || !row.reachableAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const host = row.exitNodeSubnet.split("/")[0];
|
||||||
|
const port = row.internalPort ?? row.port;
|
||||||
|
const scheme = row.method?.toLowerCase() ?? "https";
|
||||||
|
resolved.push({
|
||||||
|
targetId: row.targetId,
|
||||||
|
destination: `${scheme}://${host}:${port}`,
|
||||||
|
hostHeader: row.ip,
|
||||||
|
gerbilBaseUrl: row.reachableAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProviderTargets(
|
||||||
|
providerId: number
|
||||||
|
): Promise<ResolvedProviderTarget[]> {
|
||||||
|
const cacheKey = `aiGateway:providerTargets:${providerId}`;
|
||||||
|
const cached = localCache.get<ResolvedProviderTarget[]>(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await fetchProviderTargets(providerId);
|
||||||
|
localCache.set(cacheKey, resolved, PROVIDER_TARGETS_TTL_SEC);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-robin cursor per provider. Process-local and unpersisted - fine
|
||||||
|
// since it only needs to spread load across targets, not guarantee a
|
||||||
|
// perfectly even distribution across restarts or multiple server instances.
|
||||||
|
const roundRobinCursors = new Map<number, number>();
|
||||||
|
|
||||||
|
function pickTarget(
|
||||||
|
providerId: number,
|
||||||
|
providerTargets: ResolvedProviderTarget[]
|
||||||
|
): ResolvedProviderTarget {
|
||||||
|
const cursor = roundRobinCursors.get(providerId) ?? 0;
|
||||||
|
roundRobinCursors.set(providerId, cursor + 1);
|
||||||
|
return providerTargets[cursor % providerTargets.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromRequest(req: Request): string {
|
||||||
|
// Query string is preserved - some providers use it to select the
|
||||||
|
// streaming response format (e.g. Gemini's `?alt=sse`), and gerbil's
|
||||||
|
// /router/* forwards it through untouched.
|
||||||
|
const raw = req.originalUrl || req.url || req.path;
|
||||||
|
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxies an AI gateway request to one of a "custom" / "target" routing-mode
|
||||||
|
* provider's site targets, via that site's gerbil sidecar. Gerbil's
|
||||||
|
* /router/* endpoint forwards the request (untouched body, same path minus
|
||||||
|
* the /router prefix, and all headers besides PANGOLIN_DEST_HEADER and
|
||||||
|
* PANGOLIN_HOST_HEADER) over the WireGuard tunnel to the destination named
|
||||||
|
* in PANGOLIN_DEST_HEADER, sending PANGOLIN_HOST_HEADER as the Host header.
|
||||||
|
* Always writes a response to `res`, including on failure.
|
||||||
|
*/
|
||||||
|
export async function proxyAiGatewayToSiteTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
provider: AiProvider,
|
||||||
|
requestUser: RequestUser | null,
|
||||||
|
capability: AiCapability
|
||||||
|
): Promise<void> {
|
||||||
|
const providerTargets = await getProviderTargets(provider.providerId);
|
||||||
|
if (providerTargets.length === 0) {
|
||||||
|
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
|
error: {
|
||||||
|
message: "AI provider has no reachable site targets configured"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = pickTarget(provider.providerId, providerTargets);
|
||||||
|
const gerbilUrl = `${target.gerbilBaseUrl.replace(/\/+$/, "")}/router${pathFromRequest(req)}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (SKIP_HEADERS.has(key.toLowerCase()) || value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authType = provider.authType as AiProviderAuthType;
|
||||||
|
let apiKey: string | null = null;
|
||||||
|
if (authTypeRequiresApiKey(authType)) {
|
||||||
|
if (!provider.apiKey) {
|
||||||
|
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
|
error: {
|
||||||
|
message: "AI provider has no API key configured"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const secret = config.getRawConfig().server.secret!;
|
||||||
|
apiKey = decrypt(provider.apiKey, secret);
|
||||||
|
}
|
||||||
|
applyAiProviderCustomHeaders(
|
||||||
|
headers,
|
||||||
|
provider.headers,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
||||||
|
applyRequestUserHeaders(headers, requestUser);
|
||||||
|
|
||||||
|
headers[PANGOLIN_DEST_HEADER] = target.destination;
|
||||||
|
headers[PANGOLIN_HOST_HEADER] = target.hostHeader;
|
||||||
|
|
||||||
|
const body = JSON.stringify(req.body);
|
||||||
|
|
||||||
|
logger.debug("AI gateway target-routed request", {
|
||||||
|
providerId: provider.providerId,
|
||||||
|
targetId: target.targetId,
|
||||||
|
destination: target.destination,
|
||||||
|
hostHeader: target.hostHeader,
|
||||||
|
url: gerbilUrl,
|
||||||
|
headers,
|
||||||
|
body: req.body
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel the request to gerbil (which cascades to gerbil cancelling its
|
||||||
|
// proxied request to the actual site target, since gerbil's reverse
|
||||||
|
// proxy derives the outbound request's context from the inbound one) if
|
||||||
|
// the client goes away before we're done.
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const onClientClose = () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res.on("close", onClientClose);
|
||||||
|
|
||||||
|
let upstreamRes: globalThis.Response;
|
||||||
|
try {
|
||||||
|
upstreamRes = await fetch(gerbilUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
res.off("close", onClientClose);
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
// Client already disconnected; nothing left to respond to.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({
|
||||||
|
message: "AI gateway target proxy request failed",
|
||||||
|
url: gerbilUrl,
|
||||||
|
targetId: target.targetId,
|
||||||
|
error: fetchError,
|
||||||
|
cause:
|
||||||
|
fetchError instanceof Error
|
||||||
|
? (fetchError as Error & { cause?: unknown }).cause
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
res.status(HttpCode.BAD_GATEWAY).json({
|
||||||
|
error: { message: "Failed to reach AI provider target" }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstreamRes.headers.get("content-type") || "";
|
||||||
|
const isStream = AI_CAPABILITY_DEFS[capability].isStreaming(
|
||||||
|
req,
|
||||||
|
contentType
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(upstreamRes.status);
|
||||||
|
res.setHeader("Content-Type", contentType || "application/json");
|
||||||
|
|
||||||
|
if (isStream && upstreamRes.body) {
|
||||||
|
res.flushHeaders();
|
||||||
|
const reader = upstreamRes.body.getReader();
|
||||||
|
try {
|
||||||
|
while (!abortController.signal.aborted) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
res.write(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
res.off("close", onClientClose);
|
||||||
|
}
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.off("close", onClientClose);
|
||||||
|
const text = await upstreamRes.text();
|
||||||
|
res.send(text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiModels, aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
providerId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
modelKey: z.string().nonempty(),
|
||||||
|
name: z.string().nonempty(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
listType: modelListTypeSchema.optional().default("allow")
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/ai-provider/{providerId}/model",
|
||||||
|
description: "Create an AI model under a provider.",
|
||||||
|
tags: [OpenAPITags.AiModel],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createAiModel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedParams.data;
|
||||||
|
const { modelKey, name, enabled, listType } = parsedBody.data;
|
||||||
|
|
||||||
|
const [provider] =
|
||||||
|
req.aiProvider && req.aiProvider.providerId === providerId
|
||||||
|
? [req.aiProvider]
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ modelId: aiModels.modelId })
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModels.providerId, providerId),
|
||||||
|
eq(aiModels.modelKey, modelKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
`Model with key ${modelKey} already exists for this provider`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const [model] = await db
|
||||||
|
.insert(aiModels)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelKey,
|
||||||
|
name,
|
||||||
|
listType,
|
||||||
|
enabled: enabled ?? true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response<CreateOrEditAiModelResponse>(res, {
|
||||||
|
data: { model },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI model created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import {
|
||||||
|
resolveAiProviderCreateFields,
|
||||||
|
resolveCapabilitiesForCreate,
|
||||||
|
serializeAiProviderHeaders
|
||||||
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||||
|
import {
|
||||||
|
aiAuthTypeSchema,
|
||||||
|
aiCapabilitiesSchema,
|
||||||
|
aiProviderHeadersSchema,
|
||||||
|
aiProviderTypeSchema,
|
||||||
|
aiRoutingModeSchema,
|
||||||
|
refineProviderUpstreamFields
|
||||||
|
} from "@server/routers/aiProvider/validation";
|
||||||
|
import { serializeCapabilities } from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.strictObject({
|
||||||
|
name: z.string().nonempty(),
|
||||||
|
type: aiProviderTypeSchema,
|
||||||
|
upstreamUrl: z.url().optional().nullable(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
authType: aiAuthTypeSchema.optional(),
|
||||||
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
|
capabilities: aiCapabilitiesSchema.optional(),
|
||||||
|
headers: aiProviderHeadersSchema,
|
||||||
|
skipTlsVerification: z.boolean().optional(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
refineProviderUpstreamFields(data, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/org/{orgId}/ai-provider",
|
||||||
|
description: "Create an AI provider for an organization.",
|
||||||
|
tags: [OpenAPITags.AiProvider],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createAiProvider(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
upstreamUrl,
|
||||||
|
apiKey,
|
||||||
|
authType,
|
||||||
|
routingMode,
|
||||||
|
capabilities,
|
||||||
|
headers,
|
||||||
|
skipTlsVerification,
|
||||||
|
enabled
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
|
||||||
|
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
|
||||||
|
const now = Date.now();
|
||||||
|
const resolved = resolveAiProviderCreateFields({
|
||||||
|
type,
|
||||||
|
upstreamUrl,
|
||||||
|
authType,
|
||||||
|
routingMode
|
||||||
|
});
|
||||||
|
const resolvedCapabilities = resolveCapabilitiesForCreate({
|
||||||
|
type,
|
||||||
|
capabilities
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resolvedCapabilities.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"At least one capability is required"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [provider] = await db
|
||||||
|
.insert(aiProviders)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
upstreamUrl: resolved.upstreamUrl,
|
||||||
|
apiKey: encryptedApiKey,
|
||||||
|
apiKeyLastChars,
|
||||||
|
authType: resolved.authType,
|
||||||
|
routingMode: resolved.routingMode,
|
||||||
|
capabilities: serializeCapabilities(resolvedCapabilities),
|
||||||
|
headers: serializeAiProviderHeaders(headers, key),
|
||||||
|
skipTlsVerification: skipTlsVerification ?? false,
|
||||||
|
enabled: enabled ?? true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response<CreateOrEditAiProviderResponse>(res, {
|
||||||
|
data: {
|
||||||
|
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI provider created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiModels, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
modelId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/ai-model/{modelId}",
|
||||||
|
description: "Delete an AI model.",
|
||||||
|
tags: [OpenAPITags.AiModel],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteAiModel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ modelId: aiModels.modelId })
|
||||||
|
.from(aiModels)
|
||||||
|
.where(eq(aiModels.modelId, modelId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI model with ID ${modelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI model deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
providerId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/ai-provider/{providerId}",
|
||||||
|
description: "Delete an AI provider.",
|
||||||
|
tags: [OpenAPITags.AiProvider],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteAiProvider(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ providerId: aiProviders.providerId })
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI provider deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiModels, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { GetAiModelResponse } from "@server/routers/aiProvider/types";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
modelId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/ai-model/{modelId}",
|
||||||
|
description: "Get an AI model by ID.",
|
||||||
|
tags: [OpenAPITags.AiModel],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getAiModel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [model] =
|
||||||
|
req.aiModel && req.aiModel.modelId === modelId
|
||||||
|
? [req.aiModel]
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(aiModels)
|
||||||
|
.where(eq(aiModels.modelId, modelId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI model with ID ${modelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetAiModelResponse>(res, {
|
||||||
|
data: { model },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI model retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
providerId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/ai-provider/{providerId}",
|
||||||
|
description: "Get an AI provider by ID.",
|
||||||
|
tags: [OpenAPITags.AiProvider],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getAiProvider(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [provider] =
|
||||||
|
req.aiProvider && req.aiProvider.providerId === providerId
|
||||||
|
? [req.aiProvider]
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetAiProviderResponse>(res, {
|
||||||
|
data: {
|
||||||
|
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI provider retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export * from "./createAiProvider";
|
||||||
|
export * from "./listAiProviders";
|
||||||
|
export * from "./getAiProvider";
|
||||||
|
export * from "./updateAiProvider";
|
||||||
|
export * from "./deleteAiProvider";
|
||||||
|
export * from "./createAiModel";
|
||||||
|
export * from "./listAiModels";
|
||||||
|
export * from "./getAiModel";
|
||||||
|
export * from "./updateAiModel";
|
||||||
|
export * from "./deleteAiModel";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiModels, aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||||
|
import type { ListAiModelsResponse } from "@server/routers/aiProvider/types";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
providerId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listSchema = z.object({
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(20)
|
||||||
|
.default(20)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.catch(1)
|
||||||
|
.default(1)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/ai-provider/{providerId}/models",
|
||||||
|
description: "List AI models for a provider.",
|
||||||
|
tags: [OpenAPITags.AiModel],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: listSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listAiModels(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [provider] =
|
||||||
|
req.aiProvider && req.aiProvider.providerId === providerId
|
||||||
|
? [req.aiProvider]
|
||||||
|
: await db
|
||||||
|
.select({ providerId: aiProviders.providerId })
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageSize, page, query } = parsedQuery.data;
|
||||||
|
const conditions = [eq(aiModels.providerId, providerId)];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${aiModels.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = db
|
||||||
|
.select()
|
||||||
|
.from(aiModels)
|
||||||
|
.where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(aiModels)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_ai_models")
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalCount, rows] = await Promise.all([
|
||||||
|
countQuery,
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(aiModels.name))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<ListAiModelsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
models: rows,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI models retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||||
|
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listSchema = z.object({
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(20)
|
||||||
|
.default(20)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.catch(1)
|
||||||
|
.default(1)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/ai-providers",
|
||||||
|
description: "List AI providers for an organization.",
|
||||||
|
tags: [OpenAPITags.AiProvider],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: listSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listAiProviders(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageSize, page, query } = parsedQuery.data;
|
||||||
|
const conditions = [eq(aiProviders.orgId, orgId)];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${aiProviders.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_ai_providers")
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalCount, rows] = await Promise.all([
|
||||||
|
countQuery,
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(aiProviders.name))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<ListAiProvidersResponse>(res, {
|
||||||
|
data: {
|
||||||
|
providers: rows.map((row) => toPublicAiProvider(row)),
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI providers retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { AiModel, AiProvider } from "@server/db";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import {
|
||||||
|
parseAiProviderHeaders,
|
||||||
|
type AiProviderAuthType,
|
||||||
|
type AiProviderHeader
|
||||||
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
parseCapabilities,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
export type AiProviderPublic = Omit<
|
||||||
|
AiProvider,
|
||||||
|
"apiKey" | "capabilities" | "headers"
|
||||||
|
> & {
|
||||||
|
apiKey?: string | null;
|
||||||
|
capabilities: AiCapability[];
|
||||||
|
headers: AiProviderHeader[] | null;
|
||||||
|
effectiveUpstreamUrl: string | null;
|
||||||
|
effectiveAuthType: AiProviderAuthType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListAiProvidersResponse = PaginatedResponse<{
|
||||||
|
providers: AiProviderPublic[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GetAiProviderResponse = {
|
||||||
|
provider: AiProviderPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateOrEditAiProviderResponse = {
|
||||||
|
provider: AiProviderPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListAiModelsResponse = PaginatedResponse<{
|
||||||
|
models: AiModel[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GetAiModelResponse = {
|
||||||
|
model: AiModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateOrEditAiModelResponse = {
|
||||||
|
model: AiModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toPublicAiProvider(
|
||||||
|
provider: AiProvider,
|
||||||
|
options?: { includeApiKey?: boolean }
|
||||||
|
): AiProviderPublic {
|
||||||
|
const {
|
||||||
|
apiKey: encryptedApiKey,
|
||||||
|
capabilities: rawCapabilities,
|
||||||
|
headers: rawHeaders,
|
||||||
|
...rest
|
||||||
|
} = provider;
|
||||||
|
|
||||||
|
let apiKey: string | null | undefined;
|
||||||
|
if (options?.includeApiKey) {
|
||||||
|
if (encryptedApiKey) {
|
||||||
|
apiKey = decrypt(
|
||||||
|
encryptedApiKey,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
apiKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedHeaders = parseAiProviderHeaders(
|
||||||
|
rawHeaders,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(options?.includeApiKey ? { apiKey } : {}),
|
||||||
|
capabilities: parseCapabilities(rawCapabilities),
|
||||||
|
headers: parsedHeaders.length > 0 ? parsedHeaders : null,
|
||||||
|
effectiveUpstreamUrl: provider.upstreamUrl,
|
||||||
|
effectiveAuthType: provider.authType as AiProviderAuthType
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiModels, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
|
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
modelId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
modelKey: z.string().nonempty().optional(),
|
||||||
|
name: z.string().nonempty().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
listType: modelListTypeSchema.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/ai-model/{modelId}",
|
||||||
|
description: "Update an AI model.",
|
||||||
|
tags: [OpenAPITags.AiModel],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateAiModel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId } = parsedParams.data;
|
||||||
|
const body = parsedBody.data;
|
||||||
|
|
||||||
|
const [existing] =
|
||||||
|
req.aiModel && req.aiModel.modelId === modelId
|
||||||
|
? [req.aiModel]
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(aiModels)
|
||||||
|
.where(eq(aiModels.modelId, modelId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI model with ID ${modelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
body.modelKey !== undefined &&
|
||||||
|
body.modelKey !== existing.modelKey
|
||||||
|
) {
|
||||||
|
const [conflict] = await db
|
||||||
|
.select({ modelId: aiModels.modelId })
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModels.providerId, existing.providerId),
|
||||||
|
eq(aiModels.modelKey, body.modelKey),
|
||||||
|
ne(aiModels.modelId, modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
`Model with key ${body.modelKey} already exists for this provider`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<typeof aiModels.$inferInsert> = {
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.modelKey !== undefined) {
|
||||||
|
updateData.modelKey = body.modelKey;
|
||||||
|
}
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
updateData.name = body.name;
|
||||||
|
}
|
||||||
|
if (body.enabled !== undefined) {
|
||||||
|
updateData.enabled = body.enabled;
|
||||||
|
}
|
||||||
|
if (body.listType !== undefined) {
|
||||||
|
updateData.listType = body.listType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [model] = await db
|
||||||
|
.update(aiModels)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(aiModels.modelId, modelId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response<CreateOrEditAiModelResponse>(res, {
|
||||||
|
data: { model },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI model updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiProviders, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
|
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||||
|
import {
|
||||||
|
aiAuthTypeSchema,
|
||||||
|
aiCapabilitiesSchema,
|
||||||
|
aiProviderHeadersSchema,
|
||||||
|
aiProviderTypeSchema,
|
||||||
|
aiRoutingModeSchema,
|
||||||
|
refineProviderUpstreamFields
|
||||||
|
} from "@server/routers/aiProvider/validation";
|
||||||
|
import {
|
||||||
|
serializeAiProviderHeaders,
|
||||||
|
type AiProviderAuthType,
|
||||||
|
type AiProviderRoutingMode,
|
||||||
|
type AiProviderType
|
||||||
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
parseCapabilities,
|
||||||
|
serializeCapabilities
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
providerId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
name: z.string().nonempty().optional(),
|
||||||
|
upstreamUrl: z.url().optional().nullable(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
authType: aiAuthTypeSchema.optional(),
|
||||||
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
|
capabilities: aiCapabilitiesSchema.optional(),
|
||||||
|
headers: aiProviderHeadersSchema,
|
||||||
|
skipTlsVerification: z.boolean().optional(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/ai-provider/{providerId}",
|
||||||
|
description: "Update an AI provider.",
|
||||||
|
tags: [OpenAPITags.AiProvider],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateAiProvider(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedParams.data;
|
||||||
|
const body = parsedBody.data;
|
||||||
|
|
||||||
|
const [existing] =
|
||||||
|
req.aiProvider && req.aiProvider.providerId === providerId
|
||||||
|
? [req.aiProvider]
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`AI provider with ID ${providerId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerType = existing.type as AiProviderType;
|
||||||
|
const nextRoutingMode: AiProviderRoutingMode =
|
||||||
|
providerType === "custom"
|
||||||
|
? ((body.routingMode ??
|
||||||
|
existing.routingMode) as AiProviderRoutingMode)
|
||||||
|
: "url";
|
||||||
|
const nextUpstreamUrl =
|
||||||
|
body.upstreamUrl !== undefined
|
||||||
|
? body.upstreamUrl
|
||||||
|
: existing.upstreamUrl;
|
||||||
|
const nextAuthType: AiProviderAuthType =
|
||||||
|
body.authType !== undefined
|
||||||
|
? body.authType
|
||||||
|
: (existing.authType as AiProviderAuthType);
|
||||||
|
|
||||||
|
const nextCapabilities =
|
||||||
|
body.capabilities !== undefined
|
||||||
|
? body.capabilities
|
||||||
|
: parseCapabilities(existing.capabilities);
|
||||||
|
|
||||||
|
const validation = z
|
||||||
|
.object({
|
||||||
|
type: aiProviderTypeSchema,
|
||||||
|
upstreamUrl: z.string().nullable().optional(),
|
||||||
|
authType: aiAuthTypeSchema,
|
||||||
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
|
capabilities: aiCapabilitiesSchema.optional()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
|
||||||
|
.safeParse({
|
||||||
|
type: providerType,
|
||||||
|
upstreamUrl: nextUpstreamUrl,
|
||||||
|
authType: nextAuthType,
|
||||||
|
routingMode: nextRoutingMode,
|
||||||
|
capabilities: nextCapabilities
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(validation.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<typeof aiProviders.$inferInsert> = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
routingMode: nextRoutingMode
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
updateData.name = body.name;
|
||||||
|
}
|
||||||
|
if (body.skipTlsVerification !== undefined) {
|
||||||
|
updateData.skipTlsVerification = body.skipTlsVerification;
|
||||||
|
}
|
||||||
|
if (body.enabled !== undefined) {
|
||||||
|
updateData.enabled = body.enabled;
|
||||||
|
}
|
||||||
|
if (nextRoutingMode === "target") {
|
||||||
|
updateData.upstreamUrl = null;
|
||||||
|
} else if (body.upstreamUrl !== undefined) {
|
||||||
|
updateData.upstreamUrl = body.upstreamUrl;
|
||||||
|
}
|
||||||
|
if (body.authType !== undefined) {
|
||||||
|
updateData.authType = body.authType;
|
||||||
|
}
|
||||||
|
if (body.capabilities !== undefined) {
|
||||||
|
updateData.capabilities = serializeCapabilities(body.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.apiKey !== undefined) {
|
||||||
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
updateData.apiKey = encrypt(body.apiKey, key);
|
||||||
|
updateData.apiKeyLastChars = body.apiKey.slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.headers !== undefined) {
|
||||||
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
updateData.headers = serializeAiProviderHeaders(body.headers, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [provider] = await db
|
||||||
|
.update(aiProviders)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(aiProviders.providerId, providerId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response<CreateOrEditAiProviderResponse>(res, {
|
||||||
|
data: {
|
||||||
|
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI provider updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AI_PROVIDER_AUTH_TYPES,
|
||||||
|
providerRequiresUpstreamUrl,
|
||||||
|
type AiProviderAuthType,
|
||||||
|
type AiProviderRoutingMode,
|
||||||
|
type AiProviderType
|
||||||
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
|
export const aiProviderTypeSchema = z.enum([
|
||||||
|
"openai",
|
||||||
|
"anthropic",
|
||||||
|
"googleGemini",
|
||||||
|
"vertexAi",
|
||||||
|
"bedrock",
|
||||||
|
"microsoftFoundry",
|
||||||
|
"openRouter",
|
||||||
|
"vercelAiGateway",
|
||||||
|
"custom"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const aiAuthTypeSchema = z.enum(AI_PROVIDER_AUTH_TYPES);
|
||||||
|
|
||||||
|
export const aiRoutingModeSchema = z.enum(["url", "target"]);
|
||||||
|
|
||||||
|
export const aiCapabilitySchema = z.enum(AI_CAPABILITIES);
|
||||||
|
|
||||||
|
export const aiCapabilitiesSchema = z.array(aiCapabilitySchema);
|
||||||
|
|
||||||
|
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||||
|
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
||||||
|
const templatePattern = /\{\{[^}]+\}\}/;
|
||||||
|
|
||||||
|
export const aiProviderHeadersSchema = z
|
||||||
|
.array(z.strictObject({ name: z.string(), value: z.string() }))
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.superRefine((headers, ctx) => {
|
||||||
|
if (!headers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [index, header] of headers.entries()) {
|
||||||
|
if (!validHeaderName.test(header.name)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~).",
|
||||||
|
path: [index, "name"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!validHeaderValue.test(header.value)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"Header values may only contain printable ASCII characters and horizontal whitespace.",
|
||||||
|
path: [index, "value"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
templatePattern.test(header.name) ||
|
||||||
|
templatePattern.test(header.value)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"Header names and values must not contain template expressions such as {{value}}.",
|
||||||
|
path: [index]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function refineProviderUpstreamFields(
|
||||||
|
data: {
|
||||||
|
type: AiProviderType;
|
||||||
|
upstreamUrl?: string | null;
|
||||||
|
authType?: AiProviderAuthType | null;
|
||||||
|
routingMode?: AiProviderRoutingMode | null;
|
||||||
|
capabilities?: z.infer<typeof aiCapabilitiesSchema> | null;
|
||||||
|
},
|
||||||
|
ctx: z.RefinementCtx
|
||||||
|
) {
|
||||||
|
const routingMode = data.routingMode ?? "url";
|
||||||
|
|
||||||
|
if (data.type !== "custom" && routingMode === "target") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "routingMode target is only allowed for custom providers",
|
||||||
|
path: ["routingMode"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
providerRequiresUpstreamUrl(data.type, routingMode) &&
|
||||||
|
!data.upstreamUrl
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `upstreamUrl is required for ${data.type} providers`,
|
||||||
|
path: ["upstreamUrl"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "custom") {
|
||||||
|
const caps = data.capabilities;
|
||||||
|
if (!caps || caps.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"At least one capability is required for custom providers",
|
||||||
|
path: ["capabilities"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
data.capabilities !== undefined &&
|
||||||
|
data.capabilities !== null &&
|
||||||
|
data.capabilities.length === 0
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "At least one capability is required",
|
||||||
|
path: ["capabilities"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,7 +222,9 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { blockAccess, mode } = resource;
|
const { blockAccess, mode } = resource;
|
||||||
const dontStripSession = ["ssh", "rdp", "vnc"].includes(mode);
|
const dontStripSession = ["ssh", "rdp", "vnc", "inference"].includes(
|
||||||
|
mode
|
||||||
|
);
|
||||||
|
|
||||||
if (blockAccess) {
|
if (blockAccess) {
|
||||||
logger.debug("Resource blocked", host);
|
logger.debug("Resource blocked", host);
|
||||||
|
|||||||
@@ -255,20 +255,6 @@ export async function createClient(
|
|||||||
|
|
||||||
let newClient: Client | null = null;
|
let newClient: Client | null = null;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// TODO: more intelligent way to pick the exit node
|
|
||||||
const exitNodesList = await listExitNodes(orgId);
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
if (!randomExitNode) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
@@ -287,7 +273,6 @@ export async function createClient(
|
|||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values({
|
.values({
|
||||||
niceId,
|
niceId,
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subnet: updatedSubnet,
|
subnet: updatedSubnet,
|
||||||
|
|||||||
@@ -222,11 +222,6 @@ export async function createUserClient(
|
|||||||
|
|
||||||
let newClient: Client | null = null;
|
let newClient: Client | null = null;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// TODO: more intelligent way to pick the exit node
|
|
||||||
const exitNodesList = await listExitNodes(orgId);
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
@@ -244,7 +239,6 @@ export async function createUserClient(
|
|||||||
[newClient] = await trx
|
[newClient] = await trx
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values({
|
.values({
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
orgId,
|
orgId,
|
||||||
niceId,
|
niceId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
+221
-1
@@ -45,7 +45,9 @@ import {
|
|||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyOlmAccess,
|
verifyOlmAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyResourcePolicyAccess
|
verifyResourcePolicyAccess,
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyAiModelAccess
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||||
@@ -55,6 +57,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
|
|||||||
import { logActionAudit } from "#dynamic/middlewares";
|
import { logActionAudit } from "#dynamic/middlewares";
|
||||||
import { checkRoundTripMessage } from "./ws";
|
import { checkRoundTripMessage } from "./ws";
|
||||||
import * as labels from "@server/routers/labels";
|
import * as labels from "@server/routers/labels";
|
||||||
|
import * as aiProvider from "@server/routers/aiProvider";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
@@ -404,6 +407,20 @@ authenticated.get(
|
|||||||
siteResource.listSiteResourceClients
|
siteResource.listSiteResourceClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/site-resource/:siteResourceId/ai-models",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
siteResource.listSiteResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/site-resource/:siteResourceId/ai-providers",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
siteResource.listSiteResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site-resource/:siteResourceId/roles",
|
"/site-resource/:siteResourceId/roles",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
@@ -414,6 +431,54 @@ authenticated.post(
|
|||||||
siteResource.setSiteResourceRoles
|
siteResource.setSiteResourceRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site-resource/:siteResourceId/ai-models",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.setSiteResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site-resource/:siteResourceId/ai-models/add",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.addAiModelToSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site-resource/:siteResourceId/ai-models/remove",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.removeAiModelFromSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site-resource/:siteResourceId/ai-providers",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.setSiteResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site-resource/:siteResourceId/ai-providers/add",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.addAiProviderToSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site-resource/:siteResourceId/ai-providers/remove",
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.removeAiProviderFromSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site-resource/:siteResourceId/users",
|
"/site-resource/:siteResourceId/users",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
@@ -648,6 +713,20 @@ authenticated.get(
|
|||||||
resource.listResourceUsers
|
resource.listResourceUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/ai-models",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
resource.listResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/ai-providers",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
resource.listResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
@@ -851,6 +930,54 @@ authenticated.post(
|
|||||||
resource.setResourceUsers
|
resource.setResourceUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/ai-models",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.setResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/ai-models/add",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.addAiModelToResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/ai-models/remove",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.removeAiModelFromResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/ai-providers",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.setResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/ai-providers/add",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.addAiProviderToResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/ai-providers/remove",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.removeAiProviderFromResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource-policy/:resourcePolicyId/access-control",
|
"/resource-policy/:resourcePolicyId/access-control",
|
||||||
verifyResourcePolicyAccess,
|
verifyResourcePolicyAccess,
|
||||||
@@ -1366,6 +1493,99 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/ai-provider",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createAiProvider),
|
||||||
|
logActionAudit(ActionsEnum.createAiProvider),
|
||||||
|
aiProvider.createAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/ai-providers",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listAiProviders),
|
||||||
|
aiProvider.listAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-provider/:providerId",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getAiProvider),
|
||||||
|
aiProvider.getAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/ai-provider/:providerId/target",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.createTarget),
|
||||||
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
|
target.createTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-provider/:providerId/targets",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listTargets),
|
||||||
|
target.listTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/ai-provider/:providerId",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateAiProvider),
|
||||||
|
logActionAudit(ActionsEnum.updateAiProvider),
|
||||||
|
aiProvider.updateAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/ai-provider/:providerId",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteAiProvider),
|
||||||
|
logActionAudit(ActionsEnum.deleteAiProvider),
|
||||||
|
aiProvider.deleteAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/ai-provider/:providerId/model",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createAiModel),
|
||||||
|
logActionAudit(ActionsEnum.createAiModel),
|
||||||
|
aiProvider.createAiModel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-provider/:providerId/models",
|
||||||
|
verifyAiProviderAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listAiModels),
|
||||||
|
aiProvider.listAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-model/:modelId",
|
||||||
|
verifyAiModelAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getAiModel),
|
||||||
|
aiProvider.getAiModel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/ai-model/:modelId",
|
||||||
|
verifyAiModelAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateAiModel),
|
||||||
|
logActionAudit(ActionsEnum.updateAiModel),
|
||||||
|
aiProvider.updateAiModel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/ai-model/:modelId",
|
||||||
|
verifyAiModelAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteAiModel),
|
||||||
|
logActionAudit(ActionsEnum.deleteAiModel),
|
||||||
|
aiProvider.deleteAiModel
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/labels",
|
"/org/:orgId/labels",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
// Filter to sites with the required fields up front so the rest of the
|
// Filter to sites with the required fields up front so the rest of the
|
||||||
// function can safely treat endpoint/subnet/listenPort as defined.
|
// function can safely treat endpoint/subnet/listenPort as defined.
|
||||||
const validSites = sitesRes.filter(
|
const validSites = sitesRes.filter(
|
||||||
(s) => s.endpoint && s.subnet && s.listenPort
|
(s) => s.endpoint && s.exitNodeSubnet && s.listenPort
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validSites.length === 0) {
|
if (validSites.length === 0) {
|
||||||
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
if (
|
if (
|
||||||
peer.orgId == null ||
|
peer.orgId == null ||
|
||||||
!peer.endpoint ||
|
!peer.endpoint ||
|
||||||
!peer.subnet ||
|
!peer.exitNodeSubnet ||
|
||||||
!peer.listenPort
|
!peer.listenPort
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
// Process each site using the pre-fetched data.
|
// Process each site using the pre-fetched data.
|
||||||
for (const site of validSites) {
|
for (const site of validSites) {
|
||||||
const siteDestination: PeerDestination = {
|
const siteDestination: PeerDestination = {
|
||||||
destinationIP: site.subnet!.split("/")[0],
|
destinationIP: site.exitNodeSubnet!.split("/")[0],
|
||||||
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
addDestination(site.endpoint!, {
|
addDestination(site.endpoint!, {
|
||||||
destinationIP: peer.subnet!.split("/")[0],
|
destinationIP: peer.exitNodeSubnet!.split("/")[0],
|
||||||
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { sites, exitNodes, ExitNode } from "@server/db";
|
import { sites, exitNodes, ExitNode, clients } from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq, isNotNull, and } from "drizzle-orm";
|
import { eq, isNotNull, and } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -89,11 +89,27 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
|||||||
and(
|
and(
|
||||||
eq(sites.exitNodeId, exitNode.exitNodeId),
|
eq(sites.exitNodeId, exitNode.exitNodeId),
|
||||||
isNotNull(sites.pubKey),
|
isNotNull(sites.pubKey),
|
||||||
isNotNull(sites.subnet)
|
isNotNull(sites.exitNodeSubnet)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const peers = await Promise.all(
|
const clientsRes = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.exitNodeId, exitNode.exitNodeId),
|
||||||
|
isNotNull(clients.pubKey),
|
||||||
|
isNotNull(clients.exitNodeSubnet)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let peers: {
|
||||||
|
publicKey: string | null;
|
||||||
|
allowedIps: string[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const sitePeers = await Promise.all(
|
||||||
sitesRes.map(async (site) => {
|
sitesRes.map(async (site) => {
|
||||||
if (site.type === "wireguard") {
|
if (site.type === "wireguard") {
|
||||||
return {
|
return {
|
||||||
@@ -103,7 +119,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
|||||||
} else if (site.type === "newt") {
|
} else if (site.type === "newt") {
|
||||||
return {
|
return {
|
||||||
publicKey: site.pubKey,
|
publicKey: site.pubKey,
|
||||||
allowedIps: [site.subnet!]
|
allowedIps: [site.exitNodeSubnet!]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +129,15 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clientPeers = clientsRes.map((client) => {
|
||||||
|
return {
|
||||||
|
publicKey: client.pubKey,
|
||||||
|
allowedIps: [client.exitNodeSubnet!]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
peers = [...sitePeers, ...clientPeers];
|
||||||
|
|
||||||
const configResponse: GetConfigResponse = {
|
const configResponse: GetConfigResponse = {
|
||||||
listenPort: exitNode.listenPort || 51820,
|
listenPort: exitNode.listenPort || 51820,
|
||||||
ipAddress: exitNode.address,
|
ipAddress: exitNode.address,
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
newtId: newts.newtId,
|
newtId: newts.newtId,
|
||||||
subnet: sites.subnet,
|
subnet: sites.exitNodeSubnet,
|
||||||
listenPort: sites.listenPort,
|
listenPort: sites.listenPort,
|
||||||
publicKey: sites.publicKey,
|
publicKey: sites.publicKey,
|
||||||
endpoint: clientSitesAssociationsCache.endpoint,
|
endpoint: clientSitesAssociationsCache.endpoint,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys";
|
|||||||
import * as idp from "./idp";
|
import * as idp from "./idp";
|
||||||
import * as logs from "./auditLogs";
|
import * as logs from "./auditLogs";
|
||||||
import * as siteResource from "./siteResource";
|
import * as siteResource from "./siteResource";
|
||||||
|
import * as aiProvider from "./aiProvider";
|
||||||
import {
|
import {
|
||||||
verifyApiKey,
|
verifyApiKey,
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
@@ -31,6 +32,8 @@ import {
|
|||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyApiKeyDomainAccess,
|
verifyApiKeyDomainAccess,
|
||||||
verifyApiKeyResourcePolicyAccess,
|
verifyApiKeyResourcePolicyAccess,
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyAiModelAccess,
|
||||||
verifyUserHasAction
|
verifyUserHasAction
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -243,6 +246,26 @@ authenticated.get(
|
|||||||
siteResource.listSiteResourceClients
|
siteResource.listSiteResourceClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-models",
|
||||||
|
"/private-resource/:siteResourceId/ai-models"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
siteResource.listSiteResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-providers",
|
||||||
|
"/private-resource/:siteResourceId/ai-providers"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
siteResource.listSiteResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
[
|
[
|
||||||
"/site-resource/:siteResourceId/roles",
|
"/site-resource/:siteResourceId/roles",
|
||||||
@@ -295,6 +318,72 @@ authenticated.post(
|
|||||||
siteResource.removeRoleFromSiteResource
|
siteResource.removeRoleFromSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-models",
|
||||||
|
"/private-resource/:siteResourceId/ai-models"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.setSiteResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-models/add",
|
||||||
|
"/private-resource/:siteResourceId/ai-models/add"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.addAiModelToSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-models/remove",
|
||||||
|
"/private-resource/:siteResourceId/ai-models/remove"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.removeAiModelFromSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-providers",
|
||||||
|
"/private-resource/:siteResourceId/ai-providers"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.setSiteResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-providers/add",
|
||||||
|
"/private-resource/:siteResourceId/ai-providers/add"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.addAiProviderToSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/site-resource/:siteResourceId/ai-providers/remove",
|
||||||
|
"/private-resource/:siteResourceId/ai-providers/remove"
|
||||||
|
],
|
||||||
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
siteResource.removeAiProviderFromSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
[
|
[
|
||||||
"/site-resource/:siteResourceId/users/add",
|
"/site-resource/:siteResourceId/users/add",
|
||||||
@@ -507,6 +596,26 @@ authenticated.get(
|
|||||||
resource.listResourceUsers
|
resource.listResourceUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-models",
|
||||||
|
"/public-resource/:resourceId/ai-models"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
resource.listResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-providers",
|
||||||
|
"/public-resource/:resourceId/ai-providers"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
|
||||||
|
resource.listResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
["/resource/:resourceId", "/public-resource/:resourceId"],
|
["/resource/:resourceId", "/public-resource/:resourceId"],
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
@@ -708,6 +817,28 @@ authenticated.post(
|
|||||||
resource.setResourceRoles
|
resource.setResourceRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-models",
|
||||||
|
"/public-resource/:resourceId/ai-models"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.setResourceAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-providers",
|
||||||
|
"/public-resource/:resourceId/ai-providers"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.setResourceAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
["/resource/:resourceId/users", "/public-resource/:resourceId/users"],
|
["/resource/:resourceId/users", "/public-resource/:resourceId/users"],
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
@@ -900,6 +1031,50 @@ authenticated.post(
|
|||||||
resource.removeRoleFromResource
|
resource.removeRoleFromResource
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-models/add",
|
||||||
|
"/public-resource/:resourceId/ai-models/add"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.addAiModelToResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-models/remove",
|
||||||
|
"/public-resource/:resourceId/ai-models/remove"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.removeAiModelFromResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-providers/add",
|
||||||
|
"/public-resource/:resourceId/ai-providers/add"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.addAiProviderToResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
[
|
||||||
|
"/resource/:resourceId/ai-providers/remove",
|
||||||
|
"/public-resource/:resourceId/ai-providers/remove"
|
||||||
|
],
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
|
||||||
|
logActionAudit(ActionsEnum.setResourceAiModels),
|
||||||
|
resource.removeAiProviderFromResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
[
|
[
|
||||||
"/resource/:resourceId/users/add",
|
"/resource/:resourceId/users/add",
|
||||||
@@ -1366,3 +1541,95 @@ authenticated.get(
|
|||||||
verifyApiKeyHasAction(ActionsEnum.listResources),
|
verifyApiKeyHasAction(ActionsEnum.listResources),
|
||||||
resource.listAllResourceNames
|
resource.listAllResourceNames
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/ai-provider",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createAiProvider),
|
||||||
|
logActionAudit(ActionsEnum.createAiProvider),
|
||||||
|
aiProvider.createAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/ai-providers",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listAiProviders),
|
||||||
|
aiProvider.listAiProviders
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-provider/:providerId",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getAiProvider),
|
||||||
|
aiProvider.getAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/ai-provider/:providerId/target",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||||
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
|
target.createTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-provider/:providerId/targets",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listTargets),
|
||||||
|
target.listTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/ai-provider/:providerId",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateAiProvider),
|
||||||
|
logActionAudit(ActionsEnum.updateAiProvider),
|
||||||
|
aiProvider.updateAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/ai-provider/:providerId",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteAiProvider),
|
||||||
|
logActionAudit(ActionsEnum.deleteAiProvider),
|
||||||
|
aiProvider.deleteAiProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/ai-provider/:providerId/model",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createAiModel),
|
||||||
|
logActionAudit(ActionsEnum.createAiModel),
|
||||||
|
aiProvider.createAiModel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-provider/:providerId/models",
|
||||||
|
verifyApiKeyAiProviderAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listAiModels),
|
||||||
|
aiProvider.listAiModels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/ai-model/:modelId",
|
||||||
|
verifyApiKeyAiModelAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getAiModel),
|
||||||
|
aiProvider.getAiModel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/ai-model/:modelId",
|
||||||
|
verifyApiKeyAiModelAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateAiModel),
|
||||||
|
logActionAudit(ActionsEnum.updateAiModel),
|
||||||
|
aiProvider.updateAiModel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/ai-model/:modelId",
|
||||||
|
verifyApiKeyAiModelAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteAiModel),
|
||||||
|
logActionAudit(ActionsEnum.deleteAiModel),
|
||||||
|
aiProvider.deleteAiModel
|
||||||
|
);
|
||||||
|
|||||||
@@ -63,3 +63,4 @@ internalRouter.use("/badger", badgerRouter);
|
|||||||
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
||||||
|
|
||||||
badgerRouter.post("/exchange-session", badger.exchangeSession);
|
badgerRouter.post("/exchange-session", badger.exchangeSession);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { SiteResource } from "@server/db";
|
||||||
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
|
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
|
||||||
|
|
||||||
export type SiteResourceDestinationInput = {
|
export type SiteResourceDestinationInput = {
|
||||||
mode: "host" | "cidr" | "http" | "ssh";
|
mode: SiteResource["mode"];
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
destinationPort: number | null;
|
destinationPort: number | null;
|
||||||
scheme: "http" | "https" | null;
|
scheme: "http" | "https" | null;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray, or, isNotNull, sql } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import {
|
import {
|
||||||
@@ -211,7 +211,8 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
// call rather than letting each resource fetch its own — with thousands
|
// call rather than letting each resource fetch its own — with thousands
|
||||||
// of resources this avoids a concurrent DB/cache stampede for what is
|
// of resources this avoids a concurrent DB/cache stampede for what is
|
||||||
// often the very same (e.g. wildcard) certificate.
|
// often the very same (e.g. wildcard) certificate.
|
||||||
const certByDomain = await batchFetchCertsForSiteResources(allSiteResources);
|
const certByDomain =
|
||||||
|
await batchFetchCertsForSiteResources(allSiteResources);
|
||||||
|
|
||||||
const resourceTargetsArr = await Promise.all(
|
const resourceTargetsArr = await Promise.all(
|
||||||
allSiteResources.map((resource) =>
|
allSiteResources.map((resource) =>
|
||||||
@@ -240,7 +241,7 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
version?: string | null,
|
version?: string | null,
|
||||||
remoteExitNodeId?: string
|
remoteExitNodeId?: string
|
||||||
) {
|
) {
|
||||||
// Get all enabled targets with their resource mode information
|
// Get enabled HTTP/TCP/UDP targets for resources and AI providers
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: targets.resourceId,
|
resourceId: targets.resourceId,
|
||||||
@@ -250,15 +251,18 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
port: targets.port,
|
port: targets.port,
|
||||||
internalPort: targets.internalPort,
|
internalPort: targets.internalPort,
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
mode: resources.mode
|
mode: sql<string>`COALESCE(${resources.mode}, ${targets.mode})`.mapWith(
|
||||||
|
String
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.leftJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(targets.siteId, siteId),
|
eq(targets.siteId, siteId),
|
||||||
eq(targets.enabled, true),
|
eq(targets.enabled, true),
|
||||||
inArray(targets.mode, ["http", "udp", "tcp"])
|
inArray(targets.mode, ["http", "udp", "tcp"]),
|
||||||
|
or(isNotNull(targets.resourceId), isNotNull(targets.providerId))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+12
-28
@@ -2,14 +2,17 @@ import { db, sites } from "@server/db";
|
|||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { exitNodes, Newt } from "@server/db";
|
import { exitNodes, Newt } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ne, eq, or, and, count } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||||
|
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
|
||||||
|
|
||||||
export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
export const handleNewtExitNodesRequestMessage: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
const newt = client as Newt;
|
const newt = client as Newt;
|
||||||
|
|
||||||
logger.info("Handling ping request newt message!");
|
logger.info("Handling exit nodes request newt message!");
|
||||||
|
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
logger.warn("Newt not found");
|
logger.warn("Newt not found");
|
||||||
@@ -54,32 +57,13 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
const exitNodesPayload = await Promise.all(
|
const exitNodesPayload = await Promise.all(
|
||||||
exitNodesList.map(async (node) => {
|
exitNodesList.map(async (node) => {
|
||||||
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
const weight = await calculateExitNodeWeight(
|
||||||
// higher = more desirable
|
node.exitNodeId,
|
||||||
// like saying, this node has x% of its capacity left
|
node.maxConnections
|
||||||
|
);
|
||||||
|
|
||||||
let weight = 1;
|
if (weight === null) {
|
||||||
const maxConnections = node.maxConnections;
|
return null;
|
||||||
if (maxConnections !== null && maxConnections !== undefined) {
|
|
||||||
const [currentConnections] = await db
|
|
||||||
.select({
|
|
||||||
count: count()
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sites.exitNodeId, node.exitNodeId),
|
|
||||||
eq(sites.online, true)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentConnections.count >= maxConnections) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
weight =
|
|
||||||
(maxConnections - currentConnections.count) /
|
|
||||||
maxConnections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (
|
if (
|
||||||
exitNode.reachableAt &&
|
exitNode.reachableAt &&
|
||||||
existingSite.subnet &&
|
existingSite.exitNodeSubnet &&
|
||||||
existingSite.listenPort
|
existingSite.listenPort
|
||||||
) {
|
) {
|
||||||
const payload = {
|
const payload = {
|
||||||
oldDestination: {
|
oldDestination: {
|
||||||
destinationIP: existingSite.subnet?.split("/")[0],
|
destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
|
||||||
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
},
|
},
|
||||||
newDestination: {
|
newDestination: {
|
||||||
destinationIP: site.subnet?.split("/")[0],
|
destinationIP: site.exitNodeSubnet?.split("/")[0],
|
||||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,7 +132,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
|
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format
|
const targetsToSend = await convertTargetsIfNecessary(
|
||||||
|
newt.newtId,
|
||||||
|
dedupedTargets
|
||||||
|
); // for backward compatibility with old newt versions that don't support the new target format
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
|
import { db, newts, remoteExitNodes } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { exitNodes, Newt, sites } from "@server/db";
|
import { exitNodes, Newt, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
|
||||||
import {
|
import {
|
||||||
|
ExitNodePingResult,
|
||||||
selectBestExitNode,
|
selectBestExitNode,
|
||||||
verifyExitNodeOrgAccess
|
verifyExitNodeOrgAccess
|
||||||
} from "#dynamic/lib/exitNodes";
|
} from "#dynamic/lib/exitNodes";
|
||||||
|
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||||
import { fetchContainers } from "./dockerSocket";
|
import { fetchContainers } from "./dockerSocket";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export type ExitNodePingResult = {
|
|
||||||
exitNodeId: number;
|
|
||||||
latencyMs: number;
|
|
||||||
weight: number;
|
|
||||||
error?: string;
|
|
||||||
exitNodeName: string;
|
|
||||||
endpoint: string;
|
|
||||||
wasPreviouslyConnected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
const newt = client as Newt;
|
const newt = client as Newt;
|
||||||
@@ -94,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
fetchContainers(newt.newtId);
|
fetchContainers(newt.newtId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let siteSubnet = oldSite.subnet;
|
let siteSubnet = oldSite.exitNodeSubnet;
|
||||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
if (
|
||||||
|
exitNodeId &&
|
||||||
|
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
|
||||||
|
) {
|
||||||
// This effectively moves the exit node to the new one
|
// This effectively moves the exit node to the new one
|
||||||
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
|
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
|
||||||
|
|
||||||
@@ -115,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSubnet = await getUniqueSubnetForSite(exitNode);
|
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||||
|
|
||||||
if (!newSubnet) {
|
if (!newSubnet) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -131,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.set({
|
.set({
|
||||||
pubKey: publicKey,
|
pubKey: publicKey,
|
||||||
exitNodeId: exitNodeId,
|
exitNodeId: exitNodeId,
|
||||||
subnet: newSubnet
|
exitNodeSubnet: newSubnet
|
||||||
})
|
})
|
||||||
.where(eq(sites.siteId, siteId))
|
.where(eq(sites.siteId, siteId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -250,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
excludeSender: false // Include sender in broadcast
|
excludeSender: false // Include sender in broadcast
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getUniqueSubnetForSite(
|
|
||||||
exitNode: ExitNode,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<string | null> {
|
|
||||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
|
||||||
|
|
||||||
return await lockManager.withLock(
|
|
||||||
lockKey,
|
|
||||||
async () => {
|
|
||||||
const sitesQuery = await trx
|
|
||||||
.select({
|
|
||||||
subnet: sites.subnet
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
|
|
||||||
|
|
||||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
|
||||||
const subnets = sitesQuery
|
|
||||||
.map((site) => site.subnet)
|
|
||||||
.filter(
|
|
||||||
(subnet) =>
|
|
||||||
subnet &&
|
|
||||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
|
||||||
)
|
|
||||||
.filter((subnet) => subnet !== null);
|
|
||||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
|
||||||
const newSubnet = findNextAvailableCidr(
|
|
||||||
subnets,
|
|
||||||
blockSize,
|
|
||||||
exitNode.address
|
|
||||||
);
|
|
||||||
return newSubnet;
|
|
||||||
},
|
|
||||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export * from "./handleNewtRegisterMessage";
|
|||||||
export * from "./handleReceiveBandwidthMessage";
|
export * from "./handleReceiveBandwidthMessage";
|
||||||
export * from "./handleNewtGetConfigMessage";
|
export * from "./handleNewtGetConfigMessage";
|
||||||
export * from "./handleSocketMessages";
|
export * from "./handleSocketMessages";
|
||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtExitNodesRequestMessage";
|
||||||
export * from "./handleApplyBlueprintMessage";
|
export * from "./handleApplyBlueprintMessage";
|
||||||
export * from "./handleNewtPingMessage";
|
export * from "./handleNewtPingMessage";
|
||||||
export * from "./handleNewtDisconnectingMessage";
|
export * from "./handleNewtDisconnectingMessage";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import logger from "@server/logger";
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
import { addPeer, deletePeer } from "../newt/peers";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { SiR } from "react-icons/si";
|
||||||
|
|
||||||
export async function buildSiteConfigurationForOlmClient(
|
export async function buildSiteConfigurationForOlmClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -38,6 +39,8 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
aliases: Alias[];
|
aliases: Alias[];
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
|
let exitNodeAliases: string[] = [];
|
||||||
|
|
||||||
// Get all sites data
|
// Get all sites data
|
||||||
const sitesData = await db
|
const sitesData = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -48,10 +51,6 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
)
|
)
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
if (sitesData.length === 0) {
|
|
||||||
return siteConfigurations;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch-fetch every site resource this client has access to across ALL sites
|
// Batch-fetch every site resource this client has access to across ALL sites
|
||||||
// in a single query, then group by siteId in memory. This avoids issuing one
|
// in a single query, then group by siteId in memory. This avoids issuing one
|
||||||
// query per site (which would be N round-trips for N sites).
|
// query per site (which would be N round-trips for N sites).
|
||||||
@@ -68,8 +67,8 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
clientSiteResourcesAssociationsCache.siteResourceId
|
clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
.leftJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
.leftJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(
|
||||||
@@ -81,7 +80,15 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||||
|
let siteResourcesForExitNode = [];
|
||||||
for (const row of allClientSiteResources) {
|
for (const row of allClientSiteResources) {
|
||||||
|
if (row.siteResource.requiresExitNodeConnection) {
|
||||||
|
siteResourcesForExitNode.push(row.siteResource);
|
||||||
|
}
|
||||||
|
if (!row.siteId) {
|
||||||
|
// because we are doing a leftJoin above to get the inference resources without a network / sites
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const arr = siteResourcesBySiteId.get(row.siteId);
|
const arr = siteResourcesBySiteId.get(row.siteId);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
arr.push(row.siteResource);
|
arr.push(row.siteResource);
|
||||||
@@ -90,6 +97,17 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exitNodeAliases = siteResourcesForExitNode
|
||||||
|
.map((sr) => sr.fullDomain || sr.alias) // take either in case we introduce different resource types that don't have a fullDomain
|
||||||
|
.filter((a) => a != null);
|
||||||
|
|
||||||
|
if (sitesData.length == 0) {
|
||||||
|
return {
|
||||||
|
siteConfigurations,
|
||||||
|
exitNodeAliases
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
|
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
|
||||||
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
|
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
|
||||||
if (!jitMode && relay) {
|
if (!jitMode && relay) {
|
||||||
@@ -167,7 +185,7 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
peerOps.push(deletePeer(site.siteId, client.pubKey!));
|
peerOps.push(deletePeer(site.siteId, client.pubKey!));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site.subnet) {
|
if (!site.exitNodeSubnet) {
|
||||||
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -226,5 +244,8 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return siteConfigurations;
|
return {
|
||||||
|
siteConfigurations,
|
||||||
|
exitNodeAliases
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { db, clients } from "@server/db";
|
||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { exitNodes, Olm } from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||||
|
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
|
||||||
|
|
||||||
|
export const handleOlmExitNodesRequestMessage: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
const { message, client: olmClient, sendToClient } = context;
|
||||||
|
const olm = olmClient as Olm;
|
||||||
|
|
||||||
|
logger.info("Handling exit nodes request olm message!");
|
||||||
|
|
||||||
|
if (!olm) {
|
||||||
|
logger.warn("olm not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the olm's orgId through the client relationship
|
||||||
|
if (!olm.clientId) {
|
||||||
|
logger.warn("olm clientId not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select({ orgId: clients.orgId })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, olm.clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client || !client.orgId) {
|
||||||
|
logger.warn("client not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { noCloud, chainId } = message.data;
|
||||||
|
|
||||||
|
const exitNodesList = await listExitNodes(
|
||||||
|
client.orgId,
|
||||||
|
true,
|
||||||
|
noCloud || false,
|
||||||
|
olm.clientId
|
||||||
|
); // filter for only the online ones
|
||||||
|
|
||||||
|
let lastExitNodeId = null;
|
||||||
|
if (olm.clientId) {
|
||||||
|
const [lastExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, olm.clientId))
|
||||||
|
.limit(1);
|
||||||
|
lastExitNodeId = lastExitNode?.exitNodeId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitNodesPayload = await Promise.all(
|
||||||
|
exitNodesList.map(async (node) => {
|
||||||
|
const weight = await calculateExitNodeWeight(
|
||||||
|
node.exitNodeId,
|
||||||
|
node.maxConnections
|
||||||
|
);
|
||||||
|
|
||||||
|
if (weight === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitNodeId: node.exitNodeId,
|
||||||
|
exitNodeName: node.name,
|
||||||
|
endpoint: node.endpoint,
|
||||||
|
weight,
|
||||||
|
wasPreviouslyConnected: node.exitNodeId === lastExitNodeId
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// filter out null values
|
||||||
|
const filteredExitNodes = exitNodesPayload.filter((node) => node !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
type: "olm/ping/exitNodes",
|
||||||
|
data: {
|
||||||
|
exitNodes: filteredExitNodes,
|
||||||
|
chainId: chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false, // Send to all clients
|
||||||
|
excludeSender: false // Include sender in broadcast
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, orgs, primaryDb } from "@server/db";
|
import { db, ExitNode, exitNodes, orgs, primaryDb } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
@@ -22,6 +22,13 @@ import { canCompress } from "@server/lib/clientVersionChecks";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
|
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
|
||||||
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
|
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import {
|
||||||
|
ExitNodePingResult,
|
||||||
|
selectBestExitNode,
|
||||||
|
verifyExitNodeOrgAccess
|
||||||
|
} from "#dynamic/lib/exitNodes";
|
||||||
|
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||||
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
|
|
||||||
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
||||||
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
||||||
@@ -49,11 +56,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
olmAgent,
|
olmAgent,
|
||||||
orgId,
|
orgId,
|
||||||
userToken,
|
userToken,
|
||||||
|
pingResults,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
postures,
|
postures,
|
||||||
|
backwardsCompatible,
|
||||||
chainId
|
chainId
|
||||||
} = message.data;
|
} = message.data;
|
||||||
|
|
||||||
|
if (backwardsCompatible) {
|
||||||
|
logger.debug(
|
||||||
|
"[handleOlmRegisterMessage] Backwards compatible mode detected - not sending connect message and waiting for ping response."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
||||||
@@ -284,7 +300,64 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.pubKey !== publicKey || client.archived) {
|
let exitNodeId: number | undefined;
|
||||||
|
if (pingResults) {
|
||||||
|
const bestPingResult = selectBestExitNode(
|
||||||
|
pingResults as ExitNodePingResult[]
|
||||||
|
);
|
||||||
|
if (!bestPingResult) {
|
||||||
|
logger.warn("No suitable exit node found based on ping results");
|
||||||
|
}
|
||||||
|
exitNodeId = bestPingResult?.exitNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let clientSubnet = client.exitNodeSubnet;
|
||||||
|
if (
|
||||||
|
exitNodeId &&
|
||||||
|
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
|
||||||
|
) {
|
||||||
|
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
|
||||||
|
exitNodeId,
|
||||||
|
client.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.warn("[handleOlmRegisterMessage] Exit node not found", {
|
||||||
|
orgId: client.orgId,
|
||||||
|
clientId: client.clientId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
logger.warn(
|
||||||
|
"[handleOlmRegisterMessage] Not authorized to use this exit node",
|
||||||
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT?
|
||||||
|
|
||||||
|
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||||
|
|
||||||
|
if (!newSubnet) {
|
||||||
|
logger.error(
|
||||||
|
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||||
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSubnet = newSubnet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
client.pubKey !== publicKey ||
|
||||||
|
client.archived ||
|
||||||
|
client.exitNodeId !== exitNodeId ||
|
||||||
|
client.exitNodeSubnet !== clientSubnet
|
||||||
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||||
{ orgId: client.orgId, clientId: client.clientId }
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
@@ -294,7 +367,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.update(clients)
|
.update(clients)
|
||||||
.set({
|
.set({
|
||||||
pubKey: publicKey,
|
pubKey: publicKey,
|
||||||
archived: false
|
archived: false,
|
||||||
|
exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it
|
||||||
|
exitNodeSubnet: clientSubnet
|
||||||
})
|
})
|
||||||
.where(eq(clients.clientId, client.clientId));
|
.where(eq(clients.clientId, client.clientId));
|
||||||
|
|
||||||
@@ -319,6 +394,24 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client.pubKey && client.pubKey !== publicKey && client.exitNodeId) {
|
||||||
|
// test the old client to see if its different then remove
|
||||||
|
logger.info("Public key mismatch. Deleting old peer...");
|
||||||
|
await deletePeer(client.exitNodeId, client.pubKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientSubnet && exitNodeId) {
|
||||||
|
try {
|
||||||
|
// add the peer to the exit node so it can connect
|
||||||
|
await addPeer(exitNodeId, {
|
||||||
|
publicKey: publicKey,
|
||||||
|
allowedIps: [clientSubnet]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to add peer to exit node: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let staleHolePunchChainCount: number | undefined;
|
let staleHolePunchChainCount: number | undefined;
|
||||||
const hasChainId =
|
const hasChainId =
|
||||||
chainId !== undefined && chainId !== null && String(chainId) !== "";
|
chainId !== undefined && chainId !== null && String(chainId) !== "";
|
||||||
@@ -376,15 +469,29 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let exitNode: ExitNode | null = null;
|
||||||
|
if (exitNodeId) {
|
||||||
|
[exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||||
await waitForClientRebuildIdle(olm.clientId);
|
await waitForClientRebuildIdle(olm.clientId);
|
||||||
|
|
||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
const { siteConfigurations, exitNodeAliases } =
|
||||||
client,
|
await buildSiteConfigurationForOlmClient(
|
||||||
publicKey,
|
client,
|
||||||
relay,
|
publicKey,
|
||||||
jitMode
|
relay,
|
||||||
);
|
jitMode
|
||||||
|
);
|
||||||
|
|
||||||
|
// logger.info(
|
||||||
|
// `ExitNode Aliases: ${exitNodeAliases}`
|
||||||
|
// );
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
// Return connect message with all site configurations
|
||||||
return {
|
return {
|
||||||
@@ -394,6 +501,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
sites: siteConfigurations,
|
sites: siteConfigurations,
|
||||||
tunnelIP: client.subnet,
|
tunnelIP: client.subnet,
|
||||||
utilitySubnet: org.utilitySubnet,
|
utilitySubnet: org.utilitySubnet,
|
||||||
|
exitNode:
|
||||||
|
exitNode && client.exitNodeSubnet
|
||||||
|
? {
|
||||||
|
aliases: exitNodeAliases,
|
||||||
|
connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias
|
||||||
|
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||||
|
publicKey: exitNode.publicKey,
|
||||||
|
serverIP: exitNode.address.split("/")[0],
|
||||||
|
tunnelIP: `${client.exitNodeSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
chainId: chainId
|
chainId: chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from "./handleOlmServerInitAddPeerHandshake";
|
|||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
export * from "./handleOlmUnLocalMessage";
|
export * from "./handleOlmUnLocalMessage";
|
||||||
export * from "./handleOlmLocalMessage";
|
export * from "./handleOlmLocalMessage";
|
||||||
|
export * from "./handleOlmExitNodesRequestMessage";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
db,
|
db,
|
||||||
|
ExitNode,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
Olm,
|
Olm,
|
||||||
sites,
|
sites,
|
||||||
@@ -48,12 +49,26 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
|
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
|
||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
const { siteConfigurations, exitNodeAliases } =
|
||||||
client,
|
await buildSiteConfigurationForOlmClient(
|
||||||
client.pubKey,
|
client,
|
||||||
false,
|
client.pubKey,
|
||||||
jitMode
|
false,
|
||||||
);
|
jitMode
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exit node the client itself is assigned to (for site resources hosted
|
||||||
|
// on it, e.g. inference), same as what's sent in the initial olm/wg/connect
|
||||||
|
// message. This is separate from exitNodesData below, which is only the set
|
||||||
|
// of exit nodes used for hole punching to reach site peers.
|
||||||
|
let clientExitNode: ExitNode | null = null;
|
||||||
|
if (client.exitNodeId) {
|
||||||
|
[clientExitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, client.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Get all exit nodes from sites where the client has peers
|
// Get all exit nodes from sites where the client has peers
|
||||||
const clientSites = await db
|
const clientSites = await db
|
||||||
@@ -113,11 +128,23 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
|||||||
type: "olm/sync",
|
type: "olm/sync",
|
||||||
data: {
|
data: {
|
||||||
sites: siteConfigurations,
|
sites: siteConfigurations,
|
||||||
exitNodes: exitNodesData
|
exitNodes: exitNodesData, // this is for the holepunch information
|
||||||
|
// this is for the backhaul connection to the exit node
|
||||||
|
exitNode:
|
||||||
|
clientExitNode && client.exitNodeSubnet
|
||||||
|
? {
|
||||||
|
aliases: exitNodeAliases,
|
||||||
|
connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias
|
||||||
|
endpoint: `${clientExitNode.endpoint}:${clientExitNode.listenPort}`,
|
||||||
|
publicKey: clientExitNode.publicKey,
|
||||||
|
serverIP: clientExitNode.address.split("/")[0],
|
||||||
|
tunnelIP: client.exitNodeSubnet.split("/")[0]
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
compress: canCompress(olm.version, "olm")
|
compress: canCompress(olm.version, "olm") // we dont increment the version here or we could get into a loop!
|
||||||
}
|
}
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending olm sync message:`, error);
|
logger.warn(`Error sending olm sync message:`, error);
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources, resourceAiModels } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
assertPublicModelListApiEligible,
|
||||||
|
assertPublicResourceModelEntriesValid,
|
||||||
|
modelListTypeSchema
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const addAiModelToResourceBodySchema = z.strictObject({
|
||||||
|
modelId: z.number().int().positive(),
|
||||||
|
listType: modelListTypeSchema.optional().default("allow")
|
||||||
|
});
|
||||||
|
|
||||||
|
const addAiModelToResourceParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/resource/{resourceId}/ai-models/add",
|
||||||
|
description:
|
||||||
|
"Add a single model to an inference resource allow/block selection. Requires at least one attached AI provider in select mode. The model must belong to a select-mode provider and its listType must match the provider catalog entry. listType defaults to allow.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: addAiModelToResourceParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: addAiModelToResourceBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addAiModelToResource(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = addAiModelToResourceBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId, listType } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = addAiModelToResourceParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||||
|
if (eligibleError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelError = await assertPublicResourceModelEntriesValid({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
resourceId,
|
||||||
|
models: [{ modelId, listType }]
|
||||||
|
});
|
||||||
|
if (modelError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, resourceId),
|
||||||
|
eq(resourceAiModels.modelId, modelId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Model already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(resourceAiModels)
|
||||||
|
.values({ resourceId, modelId, listType });
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Model added to resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
isInferenceFieldsError,
|
||||||
|
listPublicResourceAiProviders,
|
||||||
|
resolveProviderAttachments,
|
||||||
|
setPublicResourceAiProviders
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const addAiProviderToResourceBodySchema = z.strictObject({
|
||||||
|
providerId: z.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const addAiProviderToResourceParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/resource/{resourceId}/ai-providers/add",
|
||||||
|
description:
|
||||||
|
"Add or replace a single AI provider attachment on an inference resource. The provider is attached in inherit mode, using its own allow/block lists.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: addAiProviderToResourceParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: addAiProviderToResourceBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addAiProviderToResource(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = addAiProviderToResourceBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = addAiProviderToResourceParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.mode !== "inference") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"AI providers can only be attached to inference-mode resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await listPublicResourceAiProviders(resourceId);
|
||||||
|
const nextAttachments = [
|
||||||
|
...existing
|
||||||
|
.filter((a) => a.providerId !== providerId)
|
||||||
|
.map((a) => ({
|
||||||
|
providerId: a.providerId,
|
||||||
|
accessMode: a.accessMode,
|
||||||
|
enabled: a.enabled
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
providerId,
|
||||||
|
accessMode: "inherit" as const,
|
||||||
|
enabled: true as const
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const attachments = await resolveProviderAttachments({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
requireAtLeastOne: true,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
if (isInferenceFieldsError(attachments)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPublicResourceAiProviders(resourceId, attachments);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI provider added to resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,17 +38,24 @@ import {
|
|||||||
} from "@server/db/names";
|
} from "@server/db/names";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { LimitId } from "@server/lib/billing";
|
import { LimitId } from "@server/lib/billing";
|
||||||
|
import {
|
||||||
|
isInferenceFieldsError,
|
||||||
|
resolveProviderAttachments,
|
||||||
|
resourceAiProviderAttachmentSchema,
|
||||||
|
setPublicResourceAiProviders,
|
||||||
|
type ResourceAiProviderAttachment
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
const createResourceParamsSchema = z.strictObject({
|
const createResourceParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
function resolveModeFromLegacyFields(data: {
|
function resolveModeFromLegacyFields(data: {
|
||||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
|
||||||
http?: boolean;
|
http?: boolean;
|
||||||
protocol?: "tcp" | "udp";
|
protocol?: "tcp" | "udp";
|
||||||
}): {
|
}): {
|
||||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
|
||||||
error?: string;
|
error?: string;
|
||||||
} {
|
} {
|
||||||
if (data.mode) {
|
if (data.mode) {
|
||||||
@@ -90,11 +97,20 @@ const createHttpResourceSchema = z
|
|||||||
domainId: z.string(),
|
domainId: z.string(),
|
||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
postAuthPath: z.string().nullable().optional(),
|
postAuthPath: z.string().nullable().optional(),
|
||||||
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
|
mode: z
|
||||||
|
.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp", "inference"])
|
||||||
|
.optional(),
|
||||||
// SSH Settings
|
// SSH Settings
|
||||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||||
authDaemonPort: z.int().positive().optional(),
|
authDaemonPort: z.int().positive().optional(),
|
||||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
|
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||||
|
// Inference settings
|
||||||
|
aiProviders: z
|
||||||
|
.array(resourceAiProviderAttachmentSchema)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"For inference-mode resources: AI providers to attach. Providers are attached in inherit mode, using each provider's own allow/block lists. Effective allow model keys must be unique across attached providers."
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -365,11 +381,40 @@ async function createHttpResource(
|
|||||||
mode,
|
mode,
|
||||||
authDaemonPort,
|
authDaemonPort,
|
||||||
authDaemonMode,
|
authDaemonMode,
|
||||||
pamMode
|
pamMode,
|
||||||
|
aiProviders: aiProviderInputs
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
const subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
|
const effectiveMode = mode ?? "http";
|
||||||
|
|
||||||
|
let providerAttachments: ResourceAiProviderAttachment[] = [];
|
||||||
|
if (effectiveMode === "inference") {
|
||||||
|
// A new resource has no model selections yet, so providers always start
|
||||||
|
// in inherit mode; select can be enabled afterwards.
|
||||||
|
const resolved = await resolveProviderAttachments({
|
||||||
|
orgId,
|
||||||
|
attachments: (aiProviderInputs ?? []).map((p) => ({
|
||||||
|
providerId: p.providerId,
|
||||||
|
accessMode: "inherit" as const,
|
||||||
|
enabled: true as const
|
||||||
|
})),
|
||||||
|
requireAtLeastOne: false
|
||||||
|
});
|
||||||
|
if (isInferenceFieldsError(resolved)) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error));
|
||||||
|
}
|
||||||
|
providerAttachments = resolved;
|
||||||
|
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"AI providers can only be attached to inference-mode resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Wildcard subdomains are a paid feature
|
// Wildcard subdomains are a paid feature
|
||||||
if (subdomain && subdomain.includes("*")) {
|
if (subdomain && subdomain.includes("*")) {
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -410,7 +455,7 @@ async function createHttpResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
["ssh", "rdp", "vnc"].includes(mode!) &&
|
["ssh", "rdp", "vnc"].includes(effectiveMode) &&
|
||||||
!isLicensedOrSubscribed(
|
!isLicensedOrSubscribed(
|
||||||
orgId!,
|
orgId!,
|
||||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||||
@@ -543,7 +588,7 @@ async function createHttpResource(
|
|||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subdomain: finalSubdomain,
|
subdomain: finalSubdomain,
|
||||||
mode: mode,
|
mode: effectiveMode,
|
||||||
pamMode: pamMode,
|
pamMode: pamMode,
|
||||||
authDaemonMode: authDaemonMode,
|
authDaemonMode: authDaemonMode,
|
||||||
authDaemonPort: authDaemonPort,
|
authDaemonPort: authDaemonPort,
|
||||||
@@ -556,6 +601,14 @@ async function createHttpResource(
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (providerAttachments.length > 0) {
|
||||||
|
await setPublicResourceAiProviders(
|
||||||
|
newResource[0].resourceId,
|
||||||
|
providerAttachments,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await trx.insert(roleResources).values({
|
await trx.insert(roleResources).values({
|
||||||
roleId: adminRole[0].roleId,
|
roleId: adminRole[0].roleId,
|
||||||
resourceId: newResource[0].resourceId
|
resourceId: newResource[0].resourceId
|
||||||
|
|||||||
@@ -35,3 +35,11 @@ export * from "./removeEmailFromResourceWhitelist";
|
|||||||
export * from "./getStatusHistory";
|
export * from "./getStatusHistory";
|
||||||
export * from "./getBatchedStatusHistory";
|
export * from "./getBatchedStatusHistory";
|
||||||
export * from "./getResourcePolicies";
|
export * from "./getResourcePolicies";
|
||||||
|
export * from "./listResourceAiModels";
|
||||||
|
export * from "./setResourceAiModels";
|
||||||
|
export * from "./addAiModelToResource";
|
||||||
|
export * from "./removeAiModelFromResource";
|
||||||
|
export * from "./listResourceAiProviders";
|
||||||
|
export * from "./setResourceAiProviders";
|
||||||
|
export * from "./addAiProviderToResource";
|
||||||
|
export * from "./removeAiProviderFromResource";
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources, resourceAiModels, aiModels } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const listResourceAiModelsParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
async function query(resourceId: number) {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
modelId: aiModels.modelId,
|
||||||
|
modelKey: aiModels.modelKey,
|
||||||
|
name: aiModels.name,
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
enabled: aiModels.enabled,
|
||||||
|
listType: resourceAiModels.listType
|
||||||
|
})
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListResourceAiModelsResponse = {
|
||||||
|
models: NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/resource/{resourceId}/ai-models",
|
||||||
|
description:
|
||||||
|
"List the models this resource has selected from its select-mode providers' allow/block lists. Providers in inherit mode are not represented here; they use their own lists.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: listResourceAiModelsParamsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listResourceAiModels(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = listResourceAiModelsParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = await query(resourceId);
|
||||||
|
|
||||||
|
return response<ListResourceAiModelsResponse>(res, {
|
||||||
|
data: { models },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource AI models retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { listPublicResourceAiProviders } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const listResourceAiProvidersParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListResourceAiProvidersResponse = {
|
||||||
|
providers: Awaited<ReturnType<typeof listPublicResourceAiProviders>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/resource/{resourceId}/ai-providers",
|
||||||
|
description:
|
||||||
|
"List AI providers attached to an inference resource, including each attachment's accessMode.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: listResourceAiProvidersParamsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listResourceAiProviders(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = listResourceAiProvidersParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await listPublicResourceAiProviders(resourceId);
|
||||||
|
|
||||||
|
return response<ListResourceAiProvidersResponse>(res, {
|
||||||
|
data: { providers },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource AI providers retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -637,11 +637,12 @@ export async function listResources(
|
|||||||
${resourcePassword.passwordId}
|
${resourcePassword.passwordId}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"];
|
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const;
|
||||||
|
|
||||||
switch (authState) {
|
switch (authState) {
|
||||||
case "none":
|
case "none":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
|
// TODO: Does inference belong here?
|
||||||
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
|
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources, resourceAiModels } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { assertPublicModelListApiEligible } from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const removeAiModelFromResourceBodySchema = z.strictObject({
|
||||||
|
modelId: z.int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAiModelFromResourceParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/resource/{resourceId}/ai-models/remove",
|
||||||
|
description:
|
||||||
|
"Remove a single model from an inference resource allow/block list. Requires at least one attached AI provider.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: removeAiModelFromResourceParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: removeAiModelFromResourceBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function removeAiModelFromResource(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = removeAiModelFromResourceBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = removeAiModelFromResourceParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||||
|
if (eligibleError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, resourceId),
|
||||||
|
eq(resourceAiModels.modelId, modelId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Model not found in resource's restriction list"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAiModels.resourceId, resourceId),
|
||||||
|
eq(resourceAiModels.modelId, modelId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Model removed from resource successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
isInferenceFieldsError,
|
||||||
|
listPublicResourceAiProviders,
|
||||||
|
resolveProviderAttachments,
|
||||||
|
setPublicResourceAiProviders
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const removeAiProviderFromResourceBodySchema = z.strictObject({
|
||||||
|
providerId: z.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAiProviderFromResourceParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/resource/{resourceId}/ai-providers/remove",
|
||||||
|
description:
|
||||||
|
"Remove an AI provider attachment from an inference resource. At least one provider must remain.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: removeAiProviderFromResourceParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: removeAiProviderFromResourceBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function removeAiProviderFromResource(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = removeAiProviderFromResourceBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerId } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = removeAiProviderFromResourceParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.mode !== "inference") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"AI providers can only be attached to inference-mode resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await listPublicResourceAiProviders(resourceId);
|
||||||
|
const found = existing.find((a) => a.providerId === providerId);
|
||||||
|
if (!found) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"AI provider is not attached to this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = existing
|
||||||
|
.filter((a) => a.providerId !== providerId)
|
||||||
|
.map((a) => ({
|
||||||
|
providerId: a.providerId,
|
||||||
|
accessMode: a.accessMode,
|
||||||
|
enabled: a.enabled
|
||||||
|
}));
|
||||||
|
|
||||||
|
const attachments = await resolveProviderAttachments({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
attachments: remaining,
|
||||||
|
requireAtLeastOne: false,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
if (isInferenceFieldsError(attachments)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPublicResourceAiProviders(resourceId, attachments);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI provider removed from resource successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources, resourceAiModels } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
assertPublicModelListApiEligible,
|
||||||
|
assertPublicResourceModelEntriesValid,
|
||||||
|
resourceAiModelEntrySchema
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const setResourceAiModelsBodySchema = z.strictObject({
|
||||||
|
models: z.array(resourceAiModelEntrySchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const setResourceAiModelsParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/resource/{resourceId}/ai-models",
|
||||||
|
description:
|
||||||
|
"Replace the allow/block model selection for an inference resource. Requires at least one attached AI provider in select mode. Models must belong to a select-mode provider and their listType must match the provider catalog entry. An empty array clears the selection, which denies all models for select-mode providers.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: setResourceAiModelsParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourceAiModelsBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourceAiModels(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = setResourceAiModelsBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { models } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = setResourceAiModelsParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleError = await assertPublicModelListApiEligible(resource);
|
||||||
|
if (eligibleError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const byModelId = new Map(
|
||||||
|
models.map((m) => [m.modelId, m.listType] as const)
|
||||||
|
);
|
||||||
|
const uniqueModels = [...byModelId.entries()].map(
|
||||||
|
([modelId, listType]) => ({ modelId, listType })
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelError = await assertPublicResourceModelEntriesValid({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
resourceId,
|
||||||
|
models: uniqueModels
|
||||||
|
});
|
||||||
|
if (modelError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(resourceAiModels)
|
||||||
|
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (uniqueModels.length > 0) {
|
||||||
|
await trx.insert(resourceAiModels).values(
|
||||||
|
uniqueModels.map((m) => ({
|
||||||
|
resourceId,
|
||||||
|
modelId: m.modelId,
|
||||||
|
listType: m.listType
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI models set for resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, resources } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
isInferenceFieldsError,
|
||||||
|
resolveProviderAttachments,
|
||||||
|
resourceAiProviderAttachmentSchema,
|
||||||
|
setPublicResourceAiProviders
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const setResourceAiProvidersBodySchema = z.strictObject({
|
||||||
|
providers: z.array(resourceAiProviderAttachmentSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const setResourceAiProvidersParamsSchema = z.strictObject({
|
||||||
|
resourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/resource/{resourceId}/ai-providers",
|
||||||
|
description:
|
||||||
|
"Replace the AI providers attached to an inference resource. Each provider uses accessMode inherit (default, uses the provider's own allow/block lists) or select (uses the resource's selected subset of that provider's catalog). An empty list clears all providers. Effective allow model keys must be unique across attached providers.",
|
||||||
|
tags: [OpenAPITags.PublicResource],
|
||||||
|
request: {
|
||||||
|
params: setResourceAiProvidersParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: setResourceAiProvidersBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setResourceAiProviders(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = setResourceAiProvidersBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providers } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = setResourceAiProvidersParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.mode !== "inference") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"AI providers can only be attached to inference-mode resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await resolveProviderAttachments({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
attachments: providers,
|
||||||
|
requireAtLeastOne: false,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
if (isInferenceFieldsError(attachments)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPublicResourceAiProviders(resourceId, attachments);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI providers set for resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,8 +345,10 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
if (
|
||||||
// HANDLE UPDATING HTTP RESOURCES
|
["http", "ssh", "rdp", "vnc", "inference"].includes(resource.mode)
|
||||||
|
) {
|
||||||
|
// HANDLE UPDATING HTTP / BROWSER / INFERENCE RESOURCES
|
||||||
return await updateHttpResource(
|
return await updateHttpResource(
|
||||||
{
|
{
|
||||||
req,
|
req,
|
||||||
|
|||||||
@@ -311,13 +311,13 @@ export async function createSite(
|
|||||||
// lets also make sure there is no overlap with other sites on the exit node
|
// lets also make sure there is no overlap with other sites on the exit node
|
||||||
const sitesQuery = await db
|
const sitesQuery = await db
|
||||||
.select({
|
.select({
|
||||||
subnet: sites.subnet
|
subnet: sites.exitNodeSubnet
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
eq(sites.subnet, subnet)
|
eq(sites.exitNodeSubnet, subnet)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ export async function createSite(
|
|||||||
exitNodeId,
|
exitNodeId,
|
||||||
name,
|
name,
|
||||||
niceId: updatedNiceId!,
|
niceId: updatedNiceId!,
|
||||||
subnet,
|
exitNodeSubnet: subnet,
|
||||||
type,
|
type,
|
||||||
pubKey: pubKey || null,
|
pubKey: pubKey || null,
|
||||||
status: "approved"
|
status: "approved"
|
||||||
@@ -444,7 +444,7 @@ export async function createSite(
|
|||||||
type,
|
type,
|
||||||
dockerSocketEnabled: false,
|
dockerSocketEnabled: false,
|
||||||
online: true,
|
online: true,
|
||||||
subnet: "0.0.0.0/32",
|
exitNodeSubnet: "0.0.0.0/32",
|
||||||
status: "approved"
|
status: "approved"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function querySitesBase() {
|
|||||||
niceId: sites.niceId,
|
niceId: sites.niceId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
pubKey: sites.pubKey,
|
pubKey: sites.pubKey,
|
||||||
subnet: sites.subnet,
|
subnet: sites.exitNodeSubnet,
|
||||||
megabytesIn: sites.megabytesIn,
|
megabytesIn: sites.megabytesIn,
|
||||||
megabytesOut: sites.megabytesOut,
|
megabytesOut: sites.megabytesOut,
|
||||||
orgName: orgs.name,
|
orgName: orgs.name,
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({
|
|||||||
clientAddress: z.string().optional()
|
clientAddress: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/org/{orgId}/pick-site-defaults",
|
path: "/org/{orgId}/pick-site-defaults",
|
||||||
@@ -60,7 +59,9 @@ registry.registerPath({
|
|||||||
description: "Successful response",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema)
|
schema: createApiResponseSchema(
|
||||||
|
PickSiteDefaultsResponseDataSchema
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ export async function pickSiteDefaults(
|
|||||||
// list all of the sites on that exit node
|
// list all of the sites on that exit node
|
||||||
const sitesQuery = await db
|
const sitesQuery = await db
|
||||||
.select({
|
.select({
|
||||||
subnet: sites.subnet
|
subnet: sites.exitNodeSubnet
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
|
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, siteResources, siteResourceAiModels } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
assertSiteModelListApiEligible,
|
||||||
|
assertSiteResourceModelEntriesValid,
|
||||||
|
modelListTypeSchema
|
||||||
|
} from "@server/lib/aiInferenceResource";
|
||||||
|
|
||||||
|
const addAiModelToSiteResourceBodySchema = z.strictObject({
|
||||||
|
modelId: z.number().int().positive(),
|
||||||
|
listType: modelListTypeSchema.optional().default("allow")
|
||||||
|
});
|
||||||
|
|
||||||
|
const addAiModelToSiteResourceParamsSchema = z.strictObject({
|
||||||
|
siteResourceId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/site-resource/{siteResourceId}/ai-models/add",
|
||||||
|
description:
|
||||||
|
"Add a single model to an inference site resource allow/block selection. Requires at least one attached AI provider in select mode. The model must belong to a select-mode provider and its listType must match the provider catalog entry. listType defaults to allow.",
|
||||||
|
tags: [OpenAPITags.PrivateResource],
|
||||||
|
request: {
|
||||||
|
params: addAiModelToSiteResourceParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: addAiModelToSiteResourceBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addAiModelToSiteResource(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = addAiModelToSiteResourceBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId, listType } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteResourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [siteResource] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(eq(siteResources.siteResourceId, siteResourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!siteResource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleError =
|
||||||
|
await assertSiteModelListApiEligible(siteResource);
|
||||||
|
if (eligibleError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, eligibleError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelError = await assertSiteResourceModelEntriesValid({
|
||||||
|
orgId: siteResource.orgId,
|
||||||
|
siteResourceId,
|
||||||
|
models: [{ modelId, listType }]
|
||||||
|
});
|
||||||
|
if (modelError) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, modelError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResourceAiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResourceAiModels.siteResourceId, siteResourceId),
|
||||||
|
eq(siteResourceAiModels.modelId, modelId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Model already assigned to site resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(siteResourceAiModels).values({
|
||||||
|
siteResourceId,
|
||||||
|
modelId,
|
||||||
|
listType
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Model added to site resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user