mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-25 05:25:39 +02:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2809fbfd1 | |||
| 2d18db3597 | |||
| 77cab56fa9 | |||
| 2051e5df37 | |||
| 26f9026621 | |||
| 85b40b7164 | |||
| 753cbd45d0 | |||
| 1c2fe44c54 | |||
| 5b782a842c | |||
| 935410b15e | |||
| 89066aa5b1 | |||
| ddf89d0afa | |||
| e4ec6f7cbe | |||
| c1e5769003 | |||
| 4b31326b34 | |||
| 10d2c6438b | |||
| c3140c5da3 | |||
| 0f00a2337d | |||
| 1831b1af58 | |||
| 85257b941b | |||
| 4aa43fd14d | |||
| 90a77ee450 | |||
| d237d6545e | |||
| 262aaa2756 | |||
| 547ac2284a | |||
| 7d2af1837e | |||
| 8ae42e1852 | |||
| 9b0e049a21 | |||
| 19ce236262 | |||
| eca1c9044c | |||
| 4229ef9173 | |||
| d5ea0ecbc1 | |||
| 59f286299e | |||
| 442cefda84 | |||
| e3e1508e8a | |||
| 2e87927b83 | |||
| e65a79cc48 | |||
| 365a905e69 | |||
| bafbf6e096 | |||
| df7e26a444 | |||
| c1051db4a5 | |||
| c1caa30cb9 |
@@ -4,6 +4,10 @@ perpetual license to use, modify, and redistribute these contributions under any
|
||||
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
|
||||
represent that I have the right to grant this license for all contributed content.
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
> Please disclose how AI was used in this pull request. The use of AI does not preclude this from being merged but is an important factor in how we review your request.
|
||||
|
||||
## Description
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
@@ -7,6 +7,8 @@ inference resource has more than one AI provider.
|
||||
|
||||
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
|
||||
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
|
||||
- Model discovery: `server/routers/aiGateway/v1Models.ts` and
|
||||
`server/lib/aiModelDiscovery.ts`
|
||||
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
|
||||
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
|
||||
- Model catalog: `server/lib/aiModelCatalog.ts`
|
||||
@@ -39,6 +41,7 @@ The incoming path selects a capability before any provider logic runs.
|
||||
| `POST /v1/chat/completions` | `openai_chat` |
|
||||
| `POST /v1/responses` | `openai_responses` |
|
||||
| `POST /v1/messages` | `anthropic_messages` |
|
||||
| `GET /v1/models`, `GET /v1/models/{id}` | `v1_models` |
|
||||
| Gemini / Vertex / Bedrock routes | their respective capability ids |
|
||||
|
||||
Only attached providers that advertise that capability stay in the candidate
|
||||
@@ -47,10 +50,10 @@ set. Default capabilities do not overlap for native OpenAI vs Anthropic:
|
||||
| Provider type | Default capabilities |
|
||||
|---------------|----------------------|
|
||||
| `openai` | `openai_chat`, `openai_responses` |
|
||||
| `anthropic` | `anthropic_messages` |
|
||||
| `anthropic` | `anthropic_messages`, `v1_models` |
|
||||
| `openRouter` | `openai_chat` |
|
||||
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
|
||||
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
|
||||
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages`, `v1_models` |
|
||||
| `custom` | whatever was configured |
|
||||
|
||||
### 2. Allow / Block Lists
|
||||
@@ -128,6 +131,68 @@ Model "<id>" is ambiguous across multiple AI providers on this resource
|
||||
Typical remaining ties: two OpenAI-type providers both with `*`, or two
|
||||
customs advertising the same capability for an unknown model.
|
||||
|
||||
## Model Discovery Is Not Selection
|
||||
|
||||
`GET /v1/models` and `GET /v1/models/{id}` (`v1_models`) skip steps 3-6
|
||||
entirely. There is no requested model to disambiguate on, so the gateway does
|
||||
not pick one provider - it returns the **union** of what every attached
|
||||
provider advertising `v1_models` would accept, deduplicated by model id
|
||||
(lowest `providerId` wins a collision).
|
||||
|
||||
Discovery is answered from the gateway's own view of the allow/block lists,
|
||||
never proxied upstream. Providers that expose no `/v1/models` endpoint of their
|
||||
own still get a working listing, and a model an allow/block list forbids is
|
||||
never advertised.
|
||||
|
||||
Each provider's candidate ids come from two places:
|
||||
|
||||
| Source | Contributes |
|
||||
|--------|-------------|
|
||||
| Exact (non-wildcard) allow entries | the model key itself |
|
||||
| The model catalog for the provider's type | every catalog id matching an allow pattern |
|
||||
|
||||
Both sources are then filtered through the same
|
||||
`isAllowedByLists(id, allows, blocks)` check step 2 applies, so a block pattern
|
||||
hides a model from discovery exactly as it would reject it at request time.
|
||||
|
||||
The catalog source is what makes a wildcard allow such as `claude-*`
|
||||
enumerable. Provider types with no catalog mapping (`openRouter`,
|
||||
`vercelAiGateway`, `custom`) have nothing to expand against, so a wildcard
|
||||
allow on those types lists nothing - **add exact allow entries to make their
|
||||
models discoverable.**
|
||||
|
||||
### Where each field comes from
|
||||
|
||||
Token limits and capability flags can't be derived from an allow/block list.
|
||||
They come from the model catalog (`server/lib/aiModelCatalog.ts`), which the
|
||||
Fossorial API builds from LiteLLM:
|
||||
|
||||
| Field | Source |
|
||||
|-------|--------|
|
||||
| `max_input_tokens` | catalog `limits.input` |
|
||||
| `max_tokens` | catalog `limits.output` |
|
||||
| `capabilities` | catalog flags, mapped to the Models API shape by `capabilitiesFromCatalog` |
|
||||
| `display_name` | the configured model row's name, else the model id |
|
||||
| `created_at` | the configured model row's timestamp, else the epoch |
|
||||
|
||||
A model the catalog doesn't know (an exact allow entry for a fine-tune, say)
|
||||
reports `null` for all three metadata fields. The Models API declares them
|
||||
nullable, so that is a valid answer rather than a broken one.
|
||||
|
||||
The catalog's flags are coarser than the Models API describes: it carries a
|
||||
single `reasoning` flag with no way to distinguish adaptive from
|
||||
`budget_tokens`-style thinking, and nothing at all for batch, citations, code
|
||||
execution, PDF input, or context management. Anything it reports as unknown
|
||||
(`null`) is surfaced as unsupported rather than invented, so `capabilities`
|
||||
understates rather than overstates what a model can do.
|
||||
|
||||
The gateway does **not** query the provider's own `/v1/models`. Discovery is
|
||||
answered entirely from local state.
|
||||
|
||||
Results are ordered newest-first with the id as tie-break, and paginated with
|
||||
Anthropic's `limit` / `after_id` / `before_id` semantics (default 20, max
|
||||
1000).
|
||||
|
||||
## Examples
|
||||
|
||||
Assume each provider below is attached and enabled on the same inference
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Инструментът за кодиране на Anthropic за терминала.",
|
||||
"aiClientConfigDescriptionCodex": "Инструментът за кодиране на OpenAI за терминала.",
|
||||
"aiClientConfigDescriptionOpencode": "Отворен кодиращ агент за терминал.",
|
||||
"aiClientConfigDescriptionCursor": "AI редактор на код, базиран на VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Агентски инструмент на Google за кодиране на терминал.",
|
||||
"aiClientConfigSetup": "Настройка",
|
||||
"aiClientConfigTabCli": "Автоматичен (CLI)",
|
||||
"aiClientConfigTabManual": "Ръчна конфигурация",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Маршрутиране чрез цели на вашите сайтове",
|
||||
"aiProviderRoutingModeTargetNote": "След създаването на този доставчик, конфигурирайте целите на сайта в раздела Настройки на мрежата.",
|
||||
"aiProviderTargetNoOne": "Този доставчик няма цели. Добавете цел за маршрутиране на заявки чрез вашите сайтове.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Уебсайтовете, свързани с отдалечени възли, са недостъпни за пренасочване към AI Gateway доставчици.",
|
||||
"aiProviderSkipTlsVerification": "Пропуснете проверката на TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Деактивирайте проверката на TLS сертификат за възходящото свързване",
|
||||
"aiProviderBudget": "Бюджет",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Поддържа /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Съобщения",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Поддържа /v1/messages",
|
||||
"aiCapabilityV1Models": "Списък на модели",
|
||||
"aiCapabilityV1ModelsDescription": "Поддържа /v1/models откриване на модели",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Генериране на Съдържание",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Поддържа директния Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Бедрок Модел Активирай",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Дневници на действията",
|
||||
"logRetention": "Задържане на логове",
|
||||
"logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте",
|
||||
"logRetentionDisabledWarningTitle": "Деактивирано съхранение на дневници",
|
||||
"logRetentionDisabledWarningDescription": "{logType} не се съхраняват за тази организация, така че новите дейности няма да се показват тук. Активирайте съхранението в настройките за сигурност, за да започнете събирането на тези дневници.",
|
||||
"logRetentionDisabledWarningButton": "Отидете на настройки за сигурност",
|
||||
"requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация",
|
||||
"aiSessionLogs": "Журнали на AI Портал Сесиите",
|
||||
"aiSessionLogsDescription": "Прегледайте подканянета и транскрипции на отговори за запитванията към AI портал в тази организация",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.",
|
||||
"httpDestRequestLogsTitle": "Логове за HTTP заявки",
|
||||
"httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.",
|
||||
"httpDestAISessionLogsTitle": "Дневници за AI сесии",
|
||||
"httpDestAISessionLogsDescription": "AI заявки до шлюза и отговори на сесии, включително подканвания, отговори на модели и използване на жетони.",
|
||||
"httpDestSaveChanges": "Запази промените",
|
||||
"httpDestCreateDestination": "Създаване на дестинация",
|
||||
"httpDestUpdatedSuccess": "Дестинацията беше актуализирана успешно",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Antropický agentický kódovací nástroj pro terminál.",
|
||||
"aiClientConfigDescriptionCodex": "Agentický kódovací nástroj OpenAI pro terminál.",
|
||||
"aiClientConfigDescriptionOpencode": "Open source terminální kódovací agent.",
|
||||
"aiClientConfigDescriptionCursor": "AI editor kódu postavený na VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Agentický nástroj Google pro kódování v terminálu.",
|
||||
"aiClientConfigSetup": "Nastavení",
|
||||
"aiClientConfigTabCli": "Automatické (CLI)",
|
||||
"aiClientConfigTabManual": "Ruční konfigurace",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Směrujte přes cíle na svých stránkách",
|
||||
"aiProviderRoutingModeTargetNote": "Po vytvoření tohoto poskytovatele, nakonfigurujte cíle stránek na záložce Nastavení sítě.",
|
||||
"aiProviderTargetNoOne": "Tento poskytovatel nemá žádné cíle. Přidejte cíl pro směrování požadavků přes vaše stránky.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Stránky připojené k vzdáleným uzlům nejsou dostupné pro přesměrování na poskytovatele AI Gateway.",
|
||||
"aiProviderSkipTlsVerification": "Přeskočit ověření TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Zakázat ověření certifikátu TLS pro upstream připojení",
|
||||
"aiProviderBudget": "Rozpočet",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Podporuje /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Zprávy Anthropic",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Podporuje /v1/messages",
|
||||
"aiCapabilityV1Models": "Seznam modelů",
|
||||
"aiCapabilityV1ModelsDescription": "Podporuje objevování modelů /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Generování obsahu Gemini",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Podporuje přímé API Gemini",
|
||||
"aiCapabilityBedrockModelInvoke": "Vyvolání modelu Bedrock",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Záznamy akcí",
|
||||
"logRetention": "Zaznamenávání záznamu",
|
||||
"logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat",
|
||||
"logRetentionDisabledWarningTitle": "Zakázáno uchování logů",
|
||||
"logRetentionDisabledWarningDescription": "{logType} nejsou uchovávány pro tuto organizaci, takže nová aktivita se zde neprojeví. Aktivujte uchovávání v nastavení zabezpečení pro zahájení sběru těchto logů.",
|
||||
"logRetentionDisabledWarningButton": "Přejít na nastavení zabezpečení",
|
||||
"requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci",
|
||||
"aiSessionLogs": "Protokoly AI Gateway Session",
|
||||
"aiSessionLogsDescription": "Zobrazit uložené výzvy a odpovědi na žádosti AI brány v této organizaci",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.",
|
||||
"httpDestRequestLogsTitle": "Záznamy HTTP požadavků",
|
||||
"httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.",
|
||||
"httpDestAISessionLogsTitle": "Logy AI sezení",
|
||||
"httpDestAISessionLogsDescription": "Relace požadavků a odpovědí AI gateway, včetně podnětů, odpovědí modelů a využití tokenů.",
|
||||
"httpDestSaveChanges": "Uložit změny",
|
||||
"httpDestCreateDestination": "Vytvořit cíl",
|
||||
"httpDestUpdatedSuccess": "Cíl byl úspěšně aktualizován",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropics agentikodningsværktøj til terminalen.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAIs agentikodningsværktøj til terminalen.",
|
||||
"aiClientConfigDescriptionOpencode": "Open source terminal kodningsagent.",
|
||||
"aiClientConfigDescriptionCursor": "AI-kodeeditor bygget på VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Googles agentiske kodningsværktøj til terminalen.",
|
||||
"aiClientConfigSetup": "Opsætning",
|
||||
"aiClientConfigTabCli": "Automatisk (CLI)",
|
||||
"aiClientConfigTabManual": "Manuel Konfiguration",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Rute gennem mål på dine steder",
|
||||
"aiProviderRoutingModeTargetNote": "Efter oprettelse af denne udbyder, konfigurer mål på Netværksindstillinger fanen.",
|
||||
"aiProviderTargetNoOne": "Denne udbyder har ingen mål. Tilføj et mål for at rute forespørgsler gennem dine steder.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Websteder, der er forbundet til eksterne noder, kan ikke tilgås for at blive dirigeret til via AI Gateway-udbydere.",
|
||||
"aiProviderSkipTlsVerification": "Spring TLS-verifikation over",
|
||||
"aiProviderSkipTlsVerificationDescription": "Deaktiver TLS-certifikat verifikation for opstrømsforbindelsen",
|
||||
"aiProviderBudget": "Budget",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Understøtter /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Beskeder",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Understøtter /v1/messages",
|
||||
"aiCapabilityV1Models": "Model Liste",
|
||||
"aiCapabilityV1ModelsDescription": "Understøtter opdagelse af /v1/models modeller",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Generer Indhold",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Understøtter den direkte Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Modeller Invoker",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Handlingsloger",
|
||||
"logRetention": "Logopbevaring",
|
||||
"logRetentionDescription": "Håndter hvor længe ulike typer logs beholdes for denne organisation, eller deaktivér dem",
|
||||
"logRetentionDisabledWarningTitle": "Logbevaring deaktiveret",
|
||||
"logRetentionDisabledWarningDescription": "{logType} gemmes ikke for denne organisation, så nye aktiviteter vises ikke her. Aktiver logbevaring i sikkerhedsindstillingerne for at begynde at indsamle disse logs.",
|
||||
"logRetentionDisabledWarningButton": "Gå til sikkerhedsindstillinger",
|
||||
"requestLogsDescription": "Se detaljerede forespørgselslogs for ressourcer i denne organisation",
|
||||
"aiSessionLogs": "AI Gateway Øktsprotokoller",
|
||||
"aiSessionLogsDescription": "Se prompt og svarudskrifter for AI gateway forespørgsler i denne organisation",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Udstyrs- og tunnelforbindelseshændelser, inklusive forbindelser og frakobling.",
|
||||
"httpDestRequestLogsTitle": "HTTP-forespørgselslogs",
|
||||
"httpDestRequestLogsDescription": "HTTP-forespørgsel logs for bekræftede ressourcer, inklusive metode, sti og responskode.",
|
||||
"httpDestAISessionLogsTitle": "AI-session Logs",
|
||||
"httpDestAISessionLogsDescription": "AI-gateway anmodninger og respons-sessioner, inklusive prompts, modelresponser og tokenforbrug.",
|
||||
"httpDestSaveChanges": "Gem ændringer",
|
||||
"httpDestCreateDestination": "Opret mål",
|
||||
"httpDestUpdatedSuccess": "Målet er opdateret",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Agentisches Codierwerkzeug von Anthropic für das Terminal.",
|
||||
"aiClientConfigDescriptionCodex": "Agentisches Codierwerkzeug von OpenAI für das Terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Open-Source-Coding-Agent für das Terminal.",
|
||||
"aiClientConfigDescriptionCursor": "KI-Code-Editor basierend auf VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Googles agentic Coding-Tool für das Terminal.",
|
||||
"aiClientConfigSetup": "Einrichtung",
|
||||
"aiClientConfigTabCli": "Automatisch (CLI)",
|
||||
"aiClientConfigTabManual": "Manuelle Konfiguration",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Über Ziele auf Ihren Sites routen",
|
||||
"aiProviderRoutingModeTargetNote": "Konfigurieren Sie nach der Erstellung dieses Anbieters Site-Ziele auf der Registerkarte 'Netzwerkeinstellungen'.",
|
||||
"aiProviderTargetNoOne": "Dieser Anbieter hat keine Ziele. Fügen Sie ein Ziel hinzu, um Anfragen über Ihre Sites zu leiten.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Sites, die mit entfernten Knoten verbunden sind, können bei AI-Gateway-Anbietern nicht weitergeleitet werden.",
|
||||
"aiProviderSkipTlsVerification": "TLS-Überprüfung überspringen",
|
||||
"aiProviderSkipTlsVerificationDescription": "TLS-Zertifikatsüberprüfung für die Upstream-Verbindung deaktivieren",
|
||||
"aiProviderBudget": "Budget",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Unterstützt /v1/antworten",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Nachrichten",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Unterstützt /v1/nachrichten",
|
||||
"aiCapabilityV1Models": "Modellliste",
|
||||
"aiCapabilityV1ModelsDescription": "Unterstützt /v1/models Modellentdeckung",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Inhalt erzeugen",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Unterstützt die direkte Gemini-API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Modell Aufruf",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Aktionsprotokolle",
|
||||
"logRetention": "Log-Speicherung",
|
||||
"logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren",
|
||||
"logRetentionDisabledWarningTitle": "Protokoll-Aufbewahrung deaktiviert",
|
||||
"logRetentionDisabledWarningDescription": "{logType} werden für diese Organisation nicht aufbewahrt, daher erscheinen neue Aktivitäten hier nicht. Aktivieren Sie die Aufbewahrung in den Sicherheitseinstellungen, um diese Protokolle zu sammeln.",
|
||||
"logRetentionDisabledWarningButton": "Zu den Sicherheitseinstellungen gehen",
|
||||
"requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen",
|
||||
"aiSessionLogs": "AI-Gateway Sitzungsprotokolle",
|
||||
"aiSessionLogsDescription": "Zeigen Sie Aufforderungs- und Antwortprotokolle für Anfragen des KI-Gateways in dieser Organisation an",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.",
|
||||
"httpDestRequestLogsTitle": "HTTP Anforderungsprotokolle",
|
||||
"httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.",
|
||||
"httpDestAISessionLogsTitle": "AI-Sitzungsprotokolle",
|
||||
"httpDestAISessionLogsDescription": "AI-Gateway-Anfrage- und Antwortsitzungen, einschließlich Eingabeaufforderungen, Modellantworten und Token-Nutzung.",
|
||||
"httpDestSaveChanges": "Änderungen speichern",
|
||||
"httpDestCreateDestination": "Ziel erstellen",
|
||||
"httpDestUpdatedSuccess": "Ziel erfolgreich aktualisiert",
|
||||
|
||||
@@ -1785,6 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
|
||||
"aiClientConfigDescriptionGemini": "Google's agentic coding tool for the terminal.",
|
||||
"aiClientConfigSetup": "Setup",
|
||||
"aiClientConfigTabCli": "Automatic (CLI)",
|
||||
"aiClientConfigTabManual": "Manual Configuration",
|
||||
@@ -1890,6 +1891,7 @@
|
||||
"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.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Sites connected to remote nodes are inaccessable to be routed to on AI Gateway providers.",
|
||||
"aiProviderSkipTlsVerification": "Skip TLS Verification",
|
||||
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
|
||||
"aiProviderBudget": "Budget",
|
||||
@@ -1922,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
||||
"aiCapabilityV1Models": "Models List",
|
||||
"aiCapabilityV1ModelsDescription": "Supports /v1/models model discovery",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
||||
@@ -4083,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.",
|
||||
"httpDestRequestLogsTitle": "HTTP Request Logs",
|
||||
"httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.",
|
||||
"httpDestAISessionLogsTitle": "AI Session Logs",
|
||||
"httpDestAISessionLogsDescription": "AI gateway request and response sessions, including prompts, model responses, and token usage.",
|
||||
"httpDestSaveChanges": "Save Changes",
|
||||
"httpDestCreateDestination": "Create Destination",
|
||||
"httpDestUpdatedSuccess": "Destination updated successfully",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Herramienta de codificación agentic de Anthropic para el terminal.",
|
||||
"aiClientConfigDescriptionCodex": "Herramienta de codificación agentic de OpenAI para el terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Agente de codificación de terminal de código abierto.",
|
||||
"aiClientConfigDescriptionCursor": "Editor de código AI construido sobre VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "La herramienta de codificación de agente de Google para el terminal.",
|
||||
"aiClientConfigSetup": "Configuración",
|
||||
"aiClientConfigTabCli": "Automático (CLI)",
|
||||
"aiClientConfigTabManual": "Configuración manual",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Ruta a través de objetivos en sus sitios",
|
||||
"aiProviderRoutingModeTargetNote": "Después de crear este proveedor, configure objetivos de sitio en la pestaña de Configuración de Red.",
|
||||
"aiProviderTargetNoOne": "Este proveedor no tiene objetivos. Agregue un objetivo para enrutar solicitudes a través de sus sitios.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Los sitios conectados a nodos remotos son inaccesibles para ser enrutados a los proveedores de AI Gateway.",
|
||||
"aiProviderSkipTlsVerification": "Omitir verificación de TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Deshabilitar la verificación del certificado TLS para la conexión de upstream",
|
||||
"aiProviderBudget": "Presupuesto",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Admite /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Mensajes Antropicos",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Admite /v1/messages",
|
||||
"aiCapabilityV1Models": "Lista de Modelos",
|
||||
"aiCapabilityV1ModelsDescription": "Soporta el descubrimiento de modelos /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Generar contenido Gemini",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Admite la API directa de Gemini",
|
||||
"aiCapabilityBedrockModelInvoke": "Invocar modelo de Bedrock",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Registros de acción",
|
||||
"logRetention": "Retención de Log",
|
||||
"logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos",
|
||||
"logRetentionDisabledWarningTitle": "Retención de Registros Deshabilitada",
|
||||
"logRetentionDisabledWarningDescription": "{logType} no se están reteniendo para esta organización, por lo que la nueva actividad no aparecerá aquí. Habilita la retención en la configuración de seguridad para comenzar a recopilar estos registros.",
|
||||
"logRetentionDisabledWarningButton": "Ir a Configuración de Seguridad",
|
||||
"requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización",
|
||||
"aiSessionLogs": "Registros de Sesiones del Portal de IA",
|
||||
"aiSessionLogsDescription": "Ver transcripciones de solicitud y respuesta para solicitudes del portal de IA en esta organización",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.",
|
||||
"httpDestRequestLogsTitle": "Registros de Solicitud HTTP",
|
||||
"httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.",
|
||||
"httpDestAISessionLogsTitle": "Registros de Sesión AI",
|
||||
"httpDestAISessionLogsDescription": "Sesiones de solicitud y respuesta de AI gateway, incluyendo indicaciones, respuestas de modelos, y uso de tokens.",
|
||||
"httpDestSaveChanges": "Guardar Cambios",
|
||||
"httpDestCreateDestination": "Crear destino",
|
||||
"httpDestUpdatedSuccess": "Destino actualizado correctamente",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Outil de codage agentique d'Anthropic pour le terminal.",
|
||||
"aiClientConfigDescriptionCodex": "Outil de codage agentique d'OpenAI pour le terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Agent de codage terminal open source.",
|
||||
"aiClientConfigDescriptionCursor": "Éditeur de code IA basé sur VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Outil de codage agentique de Google pour le terminal.",
|
||||
"aiClientConfigSetup": "Configuration",
|
||||
"aiClientConfigTabCli": "Automatique (CLI)",
|
||||
"aiClientConfigTabManual": "Configuration manuelle",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Route à travers les cibles sur vos sites",
|
||||
"aiProviderRoutingModeTargetNote": "Après avoir créé ce fournisseur, configurez les cibles du site dans l'onglet Paramètres du réseau.",
|
||||
"aiProviderTargetNoOne": "Ce fournisseur n'a aucune cible. Ajoutez une cible pour acheminer les requêtes via vos sites.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Les sites connectés à des nœuds distants sont inaccessibles pour être routés vers les fournisseurs de passerelles AI.",
|
||||
"aiProviderSkipTlsVerification": "Ignorer la vérification TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Désactiver la vérification du certificat TLS pour la connexion amont",
|
||||
"aiProviderBudget": "Budget",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Prend en charge /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Messages Anthropiques",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Prend en charge /v1/messages",
|
||||
"aiCapabilityV1Models": "Liste des modèles",
|
||||
"aiCapabilityV1ModelsDescription": "Prise en charge de la découverte de modèles /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Générer du Contenu Gemini",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Prend en charge l'API directe de Gemini",
|
||||
"aiCapabilityBedrockModelInvoke": "Invocation du Modèle Bedrock",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Journaux des actions",
|
||||
"logRetention": "Journaliser la rétention",
|
||||
"logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver",
|
||||
"logRetentionDisabledWarningTitle": "Conservation des journaux désactivée",
|
||||
"logRetentionDisabledWarningDescription": "{logType} ne sont pas conservés pour cette organisation, donc aucune nouvelle activité n’apparaîtra ici. Activez la conservation dans les paramètres de sécurité pour commencer à collecter ces journaux.",
|
||||
"logRetentionDisabledWarningButton": "Aller aux paramètres de sécurité",
|
||||
"requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation",
|
||||
"aiSessionLogs": "Journaux de Session du Portail AI",
|
||||
"aiSessionLogsDescription": "Voir les transcriptions de l'invite et de la réponse pour les requêtes de portail AI dans cette organisation",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.",
|
||||
"httpDestRequestLogsTitle": "Journal des Requêtes HTTP",
|
||||
"httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.",
|
||||
"httpDestAISessionLogsTitle": "Journaux de session AI",
|
||||
"httpDestAISessionLogsDescription": "Sessions de requête et de réponse de la passerelle AI, y compris les invites, les réponses du modèle et l'utilisation des jetons.",
|
||||
"httpDestSaveChanges": "Enregistrer les modifications",
|
||||
"httpDestCreateDestination": "Créer une destination",
|
||||
"httpDestUpdatedSuccess": "Destination mise à jour avec succès",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Strumento di coding agente di Anthropic per il terminale.",
|
||||
"aiClientConfigDescriptionCodex": "Strumento di coding agente di OpenAI per il terminale.",
|
||||
"aiClientConfigDescriptionOpencode": "Agente di coding open source per il terminale.",
|
||||
"aiClientConfigDescriptionCursor": "Editor di codice AI basato su VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Lo strumento di codifica agentica di Google per il terminale.",
|
||||
"aiClientConfigSetup": "Impostazione",
|
||||
"aiClientConfigTabCli": "Automatico (CLI)",
|
||||
"aiClientConfigTabManual": "Configurazione Manuale",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Instrada tramite target sui tuoi siti",
|
||||
"aiProviderRoutingModeTargetNote": "Dopo aver creato questo provider, configura i target del sito nella scheda Impostazioni di Rete.",
|
||||
"aiProviderTargetNoOne": "Questo provider non ha alcun target. Aggiungi un target per instradare le richieste attraverso i tuoi siti.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "I siti collegati a nodi remoti non sono accessibili per essere instradati sui fornitori di AI Gateway.",
|
||||
"aiProviderSkipTlsVerification": "Salta la verifica TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Disabilita la verifica del certificato TLS per la connessione a monte",
|
||||
"aiProviderBudget": "Budget",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Supporta /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Messaggi Anthropic",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Supporta /v1/messages",
|
||||
"aiCapabilityV1Models": "Elenco dei Modelli",
|
||||
"aiCapabilityV1ModelsDescription": "Supporta la scoperta del modello /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Generazione di Contenuti Gemini",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Supporta l'API diretta di Gemini",
|
||||
"aiCapabilityBedrockModelInvoke": "Invoca Modello Bedrock",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Log Azioni",
|
||||
"logRetention": "Ritenzione Registro",
|
||||
"logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali",
|
||||
"logRetentionDisabledWarningTitle": "Conservazione del Log Disabilitata",
|
||||
"logRetentionDisabledWarningDescription": "{logType} non vengono conservati per questa organizzazione, quindi le nuove attività non appariranno qui. Abilita la conservazione nelle impostazioni di sicurezza per iniziare a raccogliere questi log.",
|
||||
"logRetentionDisabledWarningButton": "Vai alle Impostazioni di Sicurezza",
|
||||
"requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione",
|
||||
"aiSessionLogs": "Log delle Sessioni AI Gateway",
|
||||
"aiSessionLogsDescription": "Visualizza trascrizioni di prompt e risposte per le richieste del gateway AI in questa organizzazione",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.",
|
||||
"httpDestRequestLogsTitle": "Log Richieste HTTP",
|
||||
"httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.",
|
||||
"httpDestAISessionLogsTitle": "Log di Sessione AI",
|
||||
"httpDestAISessionLogsDescription": "Sessioni di richiesta e risposta AI gateway, comprese le domande, le risposte del modello e l'utilizzo dei token.",
|
||||
"httpDestSaveChanges": "Salva Modifiche",
|
||||
"httpDestCreateDestination": "Crea Destinazione",
|
||||
"httpDestUpdatedSuccess": "Destinazione aggiornata con successo",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropic의 터미널 에이전트 코딩 도구입니다.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI의 터미널 에이전트 코딩 도구입니다.",
|
||||
"aiClientConfigDescriptionOpencode": "오픈 소스 터미널 코딩 에이전트.",
|
||||
"aiClientConfigDescriptionCursor": "VS Code를 기반으로 한 AI 코드 편집기.",
|
||||
"aiClientConfigDescriptionGemini": "터미널용 구글의 에이전시 코딩 도구.",
|
||||
"aiClientConfigSetup": "설정",
|
||||
"aiClientConfigTabCli": "자동 (CLI)",
|
||||
"aiClientConfigTabManual": "수동 구성",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "사이트의 타겟을 통해 라우트",
|
||||
"aiProviderRoutingModeTargetNote": "이 공급자를 생성한 후 네트워크 설정 탭에 사이트 타겟을 구성합니다.",
|
||||
"aiProviderTargetNoOne": "이 공급자에게 타겟이 없습니다. 사이트를 통해 요청을 라우트하기 위한 타겟을 추가하십시오.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "원격 노드에 연결된 사이트는 AI 게이트웨이 공급자에게 라우팅되지 않습니다.",
|
||||
"aiProviderSkipTlsVerification": "TLS 검증 건너뛰기",
|
||||
"aiProviderSkipTlsVerificationDescription": "상류 연결에 대한 TLS 인증서 검증 비활성화",
|
||||
"aiProviderBudget": "예산",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "/v1/responses 지원",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic 메시지",
|
||||
"aiCapabilityAnthropicMessagesDescription": "/v1/messages 지원",
|
||||
"aiCapabilityV1Models": "모델 목록",
|
||||
"aiCapabilityV1ModelsDescription": "/v1/models 모델 검색 지원",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini 콘텐츠 생성",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "직접 Gemini API 지원",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock 모델 실행",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "작업 로그",
|
||||
"logRetention": "로그 보관",
|
||||
"logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다",
|
||||
"logRetentionDisabledWarningTitle": "로그 보존 비활성화",
|
||||
"logRetentionDisabledWarningDescription": "{logType}이/가 이 조직에 대해 보존되지 않으므로 새로운 활동이 여기에 나타나지 않습니다. 보안을 설정해서 보존을 활성화하여 이러한 로그를 수집하기 시작하세요.",
|
||||
"logRetentionDisabledWarningButton": "보안 설정으로 이동",
|
||||
"requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다",
|
||||
"aiSessionLogs": "AI 게이트웨이 세션 로그",
|
||||
"aiSessionLogsDescription": "이 조직의 AI 게이트웨이 요청에 대한 프롬프트 및 응답 대본을 봅니다",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.",
|
||||
"httpDestRequestLogsTitle": "HTTP 요청 로그",
|
||||
"httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.",
|
||||
"httpDestAISessionLogsTitle": "AI 세션 로그",
|
||||
"httpDestAISessionLogsDescription": "AI 게이트웨이 요청 및 응답 세션, 프롬프트, 모델 응답 및 토큰 사용을 포함합니다.",
|
||||
"httpDestSaveChanges": "변경 사항 저장",
|
||||
"httpDestCreateDestination": "대상지 생성",
|
||||
"httpDestUpdatedSuccess": "대상지가 성공적으로 업데이트되었습니다",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropics agentiske kodingsverktøy for terminalen.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAIs agentiske kodingsverktøy for terminalen.",
|
||||
"aiClientConfigDescriptionOpencode": "Åpen kildekode terminal kodeagent.",
|
||||
"aiClientConfigDescriptionCursor": "AI-kodeeditor bygget på VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Googles agentiske koding verktøy for terminalen.",
|
||||
"aiClientConfigSetup": "Oppsett",
|
||||
"aiClientConfigTabCli": "Automatisk (CLI)",
|
||||
"aiClientConfigTabManual": "Manuell konfigurasjon",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Rute gjennom mål på dine nettsteder",
|
||||
"aiProviderRoutingModeTargetNote": "Etter å ha opprettet denne leverandøren, konfigurer områdemål på fanen Nettverksinnstillinger.",
|
||||
"aiProviderTargetNoOne": "Denne leverandøren har ingen mål. Legg til et mål for å rute forespørsler gjennom dine nettsteder.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Nettsteder tilkoblet eksterne noder er utilgjengelige for ruting til på AI Gateway leverandører.",
|
||||
"aiProviderSkipTlsVerification": "Hopp over TLS-verifisering",
|
||||
"aiProviderSkipTlsVerificationDescription": "Deaktiver TLS-sertifikatverifisering for oppstrøms tilkobling",
|
||||
"aiProviderBudget": "Budsjett",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Støtter /v1/responser",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Meldinger",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Støtter /v1/meldinger",
|
||||
"aiCapabilityV1Models": "Modelliste",
|
||||
"aiCapabilityV1ModelsDescription": "Støtter /v1/modeller modelloppdagelse",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Generer Innhold",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Støtter direkte Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Modell Påkalling",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Handlingslogger",
|
||||
"logRetention": "Logg tilbaketrekning",
|
||||
"logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem",
|
||||
"logRetentionDisabledWarningTitle": "Loggbevaring deaktivert",
|
||||
"logRetentionDisabledWarningDescription": "{logType} blir ikke lagret for denne organisasjonen, så ny aktivitet vises ikke her. Aktiver lagring i sikkerhetsinnstillingene for å begynne å samle inn disse loggene.",
|
||||
"logRetentionDisabledWarningButton": "Gå til sikkerhetsinnstillinger",
|
||||
"requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen",
|
||||
"aiSessionLogs": "AI Portal Sesjonslogger",
|
||||
"aiSessionLogsDescription": "Vis stikkord- og responsutskrifter for AI-portal forespørsler i denne organisasjonen",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.",
|
||||
"httpDestRequestLogsTitle": "HTTP-forespørselslogger",
|
||||
"httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.",
|
||||
"httpDestAISessionLogsTitle": "AI øktlogger",
|
||||
"httpDestAISessionLogsDescription": "Forespørsels- og svarøkter for AI gateway, inkludert forespørsler, modellresponser og tokenbruk.",
|
||||
"httpDestSaveChanges": "Lagre endringer",
|
||||
"httpDestCreateDestination": "Opprett mål",
|
||||
"httpDestUpdatedSuccess": "Målet er oppdatert",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropic's agentische coderingstool voor de terminal.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI's agentische coderingstool voor de terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Open source terminal coderingsagent.",
|
||||
"aiClientConfigDescriptionCursor": "AI-code-editor gebouwd op VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Agentisch coderingstool van Google voor de terminal.",
|
||||
"aiClientConfigSetup": "Instellen",
|
||||
"aiClientConfigTabCli": "Automatisch (CLI)",
|
||||
"aiClientConfigTabManual": "Handmatige configuratie",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Routeer door doelen op uw sites",
|
||||
"aiProviderRoutingModeTargetNote": "Nadat u deze provider hebt aangemaakt, configureert u site-doelen op het tabblad Netwerkinstellingen.",
|
||||
"aiProviderTargetNoOne": "Deze provider heeft geen doelen. Voeg een doel toe om verzoeken via uw sites te routeren.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Sites die verbonden zijn met externe nodes zijn niet toegankelijk om naar te worden gerouteerd op AI Gateway-providers.",
|
||||
"aiProviderSkipTlsVerification": "Sla TLS-verificatie over",
|
||||
"aiProviderSkipTlsVerificationDescription": "Schakel TLS-certificaatverificatie voor de upstream-verbinding uit",
|
||||
"aiProviderBudget": "Budget",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Ondersteunt /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Berichten",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Ondersteunt /v1/messages",
|
||||
"aiCapabilityV1Models": "Modellenlijst",
|
||||
"aiCapabilityV1ModelsDescription": "Ondersteunt /v1/models modelontdekking",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Inhoud Genereren",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Ondersteunt de directe Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Model Aanroep",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Actie logs",
|
||||
"logRetention": "Log bewaring",
|
||||
"logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit",
|
||||
"logRetentionDisabledWarningTitle": "Logboekbewaring Uitgeschakeld",
|
||||
"logRetentionDisabledWarningDescription": "{logType} worden niet bewaard voor deze organisatie, dus nieuwe activiteiten zullen hier niet verschijnen. Schakel bewaren in beveiligingsinstellingen in om deze logboeken te verzamelen.",
|
||||
"logRetentionDisabledWarningButton": "Ga naar Beveiligingsinstellingen",
|
||||
"requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie",
|
||||
"aiSessionLogs": "AI Gateway Sessie Logs",
|
||||
"aiSessionLogsDescription": "Bekijk prompt- en reactie-transcripten voor AI-gateway-aanvragen in deze organisatie",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.",
|
||||
"httpDestRequestLogsTitle": "HTTP-aanvraaglogboeken",
|
||||
"httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.",
|
||||
"httpDestAISessionLogsTitle": "AI Sessielogboeken",
|
||||
"httpDestAISessionLogsDescription": "AI gateway verzoek- en reactiesessies, inclusief prompts, modelreacties en tokengebruik.",
|
||||
"httpDestSaveChanges": "Wijzigingen opslaan",
|
||||
"httpDestCreateDestination": "Maak bestemming aan",
|
||||
"httpDestUpdatedSuccess": "Bestemming succesvol bijgewerkt",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Agent narzędzia kodującego Anthropic dla terminala.",
|
||||
"aiClientConfigDescriptionCodex": "Agent narzędzia kodującego OpenAI dla terminala.",
|
||||
"aiClientConfigDescriptionOpencode": "Agent open source do kodowania w terminalu.",
|
||||
"aiClientConfigDescriptionCursor": "Edytor kodu AI oparty na VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Agent narzędzi kodowych Google dla terminala.",
|
||||
"aiClientConfigSetup": "Ustawienie",
|
||||
"aiClientConfigTabCli": "Automatyczne (CLI)",
|
||||
"aiClientConfigTabManual": "Konfiguracja ręczna",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Trasa przez cele na Twoich witrynach",
|
||||
"aiProviderRoutingModeTargetNote": "Po utworzeniu tego dostawcy, skonfiguruj cele witryny na karcie Ustawienia sieci.",
|
||||
"aiProviderTargetNoOne": "Ten dostawca nie ma żadnych celów. Dodaj cel, aby trasować zapytania przez swoje witryny.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Witryny podłączone do zdalnych węzłów są niedostępne do trasowania przez dostawców AI Gateway.",
|
||||
"aiProviderSkipTlsVerification": "Pomiń weryfikację TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Wyłącz weryfikację certyfikatu TLS dla połączenia w górę",
|
||||
"aiProviderBudget": "Budżet",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Obsługuje /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Wiadomości",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Obsługuje /v1/messages",
|
||||
"aiCapabilityV1Models": "Lista modeli",
|
||||
"aiCapabilityV1ModelsDescription": "Obsługuje odkrywanie modeli /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Generowanie Treści",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Obsługuje bezpośredni Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Model Bedrock Wywołanie",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Dzienniki działań",
|
||||
"logRetention": "Zachowanie dziennika",
|
||||
"logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je",
|
||||
"logRetentionDisabledWarningTitle": "Wyłączone przechowywanie logów",
|
||||
"logRetentionDisabledWarningDescription": "{logType} nie są przechowywane dla tej organizacji, więc nowe aktywności nie pojawią się tutaj. Włącz przechowywanie w ustawieniach bezpieczeństwa, aby zacząć zbierać te logi.",
|
||||
"logRetentionDisabledWarningButton": "Przejdź do ustawień bezpieczeństwa",
|
||||
"requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji",
|
||||
"aiSessionLogs": "Dzienniki Sesji Bramy AI",
|
||||
"aiSessionLogsDescription": "Zobacz transkrypcje podpowiedzi i odpowiedzi dla żądań bramy AI w tej organizacji",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.",
|
||||
"httpDestRequestLogsTitle": "Dzienniki żądań HTTP",
|
||||
"httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.",
|
||||
"httpDestAISessionLogsTitle": "Dzienniki sesji AI",
|
||||
"httpDestAISessionLogsDescription": "Żądania i sesje odpowiedzi bramki AI, w tym zapytania, odpowiedzi modeli i użycie tokenów.",
|
||||
"httpDestSaveChanges": "Zapisz zmiany",
|
||||
"httpDestCreateDestination": "Utwórz cel",
|
||||
"httpDestUpdatedSuccess": "Cel został pomyślnie zaktualizowany",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Ferramenta de codificação agentic de Anthropic para o terminal.",
|
||||
"aiClientConfigDescriptionCodex": "Ferramenta de codificação agentic da OpenAI para o terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Agente de codificação de terminal de código aberto.",
|
||||
"aiClientConfigDescriptionCursor": "Editor de código de IA baseado no VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Ferramenta de codificação agêntica do terminal do Google.",
|
||||
"aiClientConfigSetup": "Configuração",
|
||||
"aiClientConfigTabCli": "Automático (CLI)",
|
||||
"aiClientConfigTabManual": "Configuração Manual",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Roteie através de alvos em seus sites",
|
||||
"aiProviderRoutingModeTargetNote": "Após criar este provedor, configure alvos do site na aba Configurações de Rede.",
|
||||
"aiProviderTargetNoOne": "Este provedor não tem alvos. Adicione um alvo para rotear pedidos pelos seus sites.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Sites conectados a nós remotos estão inacessíveis para serem roteados para os provedores do Gateway de IA.",
|
||||
"aiProviderSkipTlsVerification": "Pular Verificação TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Desativar a verificação de certificado TLS para a conexão upstream",
|
||||
"aiProviderBudget": "Orçamento",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Suporta /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Mensagens Antropicas",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Suporta /v1/messages",
|
||||
"aiCapabilityV1Models": "Lista de Modelos",
|
||||
"aiCapabilityV1ModelsDescription": "Suporta descoberta de modelos /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Gêmeos Gerar Conteúdo",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Suporta a API diretta do Gêmeos",
|
||||
"aiCapabilityBedrockModelInvoke": "Modelo Bedrock Invocar",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Logs de Ações",
|
||||
"logRetention": "Retenção de Log",
|
||||
"logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los",
|
||||
"logRetentionDisabledWarningTitle": "Retenção de Logs Desativada",
|
||||
"logRetentionDisabledWarningDescription": "{logType} não estão sendo retidos para esta organização, portanto, novas atividades não aparecerão aqui. Ative a retenção nas configurações de segurança para começar a coletar esses logs.",
|
||||
"logRetentionDisabledWarningButton": "Ir para Configurações de Segurança",
|
||||
"requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização",
|
||||
"aiSessionLogs": "Registros de Sessão do Gateway de IA",
|
||||
"aiSessionLogsDescription": "Veja as transcrições de prompt e resposta para solicitações de gateway de IA nesta organização",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.",
|
||||
"httpDestRequestLogsTitle": "Registros de Pedidos HTTP",
|
||||
"httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.",
|
||||
"httpDestAISessionLogsTitle": "Logs de Sessão de IA",
|
||||
"httpDestAISessionLogsDescription": "Sessões de solicitação e resposta de gateway de IA, incluindo prompts, respostas de modelos e uso de tokens.",
|
||||
"httpDestSaveChanges": "Salvar as alterações",
|
||||
"httpDestCreateDestination": "Criar destino",
|
||||
"httpDestUpdatedSuccess": "Destino atualizado com sucesso",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Агентивное кодирующее средство Anthropic для терминала.",
|
||||
"aiClientConfigDescriptionCodex": "Агентивное кодирующее средство OpenAI для терминала.",
|
||||
"aiClientConfigDescriptionOpencode": "Открытый исходный агент для кодирования в терминале.",
|
||||
"aiClientConfigDescriptionCursor": "AI редактор кода на основе VS Code.",
|
||||
"aiClientConfigDescriptionGemini": "Инструмент программирования Google для терминала.",
|
||||
"aiClientConfigSetup": "Настройка",
|
||||
"aiClientConfigTabCli": "Автоматическое (CLI)",
|
||||
"aiClientConfigTabManual": "Ручная конфигурация",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Маршрутизация через цели на ваших сайтах",
|
||||
"aiProviderRoutingModeTargetNote": "После создания этого провайдера настройте целевые сайты на вкладке Сетевые настройки.",
|
||||
"aiProviderTargetNoOne": "У этого провайдера нет целей. Добавьте цель для маршрутизации запросов через ваши сайты.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Сайты, подключенные к удалённым узлам, недоступны для маршрутизации с помощью провайдеров AI Gateway.",
|
||||
"aiProviderSkipTlsVerification": "Пропустить проверку TLS",
|
||||
"aiProviderSkipTlsVerificationDescription": "Отключить проверку сертификата TLS для исходного соединения",
|
||||
"aiProviderBudget": "Бюджет",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Поддерживает /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Сообщения Anthropic",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Поддерживает /v1/messages",
|
||||
"aiCapabilityV1Models": "Список моделей",
|
||||
"aiCapabilityV1ModelsDescription": "Поддерживает обнаружение моделей /v1/models",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Создание контента",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Поддерживает прямой API Gemini",
|
||||
"aiCapabilityBedrockModelInvoke": "Вызов модели Bedrock",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Журнал действий",
|
||||
"logRetention": "Сохранение журнала",
|
||||
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
|
||||
"logRetentionDisabledWarningTitle": "Хранение логов отключено",
|
||||
"logRetentionDisabledWarningDescription": "Логи {logType} не сохраняются для этой организации, поэтому здесь не будет отображаться новая активность. Включите хранение в настройках безопасности, чтобы начать собирать эти логи.",
|
||||
"logRetentionDisabledWarningButton": "Перейти в настройки безопасности",
|
||||
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
|
||||
"aiSessionLogs": "AI Логи сессии шлюза",
|
||||
"aiSessionLogsDescription": "Просмотр транскриптов запросов и ответов для шлюзов AI в этой организации",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
|
||||
"httpDestRequestLogsTitle": "HTTP Запросы Логи",
|
||||
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
|
||||
"httpDestAISessionLogsTitle": "Логи AI сессий",
|
||||
"httpDestAISessionLogsDescription": "Запросы и ответы AI gateway, включая подсказки, ответы моделей и использование токенов.",
|
||||
"httpDestSaveChanges": "Сохранить изменения",
|
||||
"httpDestCreateDestination": "Создать адрес назначения",
|
||||
"httpDestUpdatedSuccess": "Адрес назначения успешно обновлен",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropic'in terminal için aracılık kodlama aracı.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI'nin terminal için aracılık kodlama aracı.",
|
||||
"aiClientConfigDescriptionOpencode": "Açık kaynak terminal kodlama aracı.",
|
||||
"aiClientConfigDescriptionCursor": "VS Code üzerine kurulu yapay zeka kod editörü.",
|
||||
"aiClientConfigDescriptionGemini": "Google'un terminal için agentik kodlama aracı.",
|
||||
"aiClientConfigSetup": "Kurulum",
|
||||
"aiClientConfigTabCli": "Otomatik (CLI)",
|
||||
"aiClientConfigTabManual": "Manuel Yapılandırma",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "Siteniz üzerindeki hedefler üzerinden yönlendirin",
|
||||
"aiProviderRoutingModeTargetNote": "Bu sağlayıcıyı oluşturduktan sonra, site hedeflerini Ağ Ayarları sekmesinde yapılandırın.",
|
||||
"aiProviderTargetNoOne": "Bu sağlayıcının herhangi bir hedefi yok. Sitemiz üzerinden istekleri yönlendirmek için bir hedef ekleyin.",
|
||||
"aiProviderRemoteNodeTargetsWarning": "Uzaktaki düğümlere bağlı siteler, AI Geçidi sağlayıcılarına yönlendirilemez durumda.",
|
||||
"aiProviderSkipTlsVerification": "TLS Doğrulamayı Atla",
|
||||
"aiProviderSkipTlsVerificationDescription": "Yukarı akış bağlantısı için TLS sertifika doğrulamasını devre dışı bırakın",
|
||||
"aiProviderBudget": "Bütçe",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "/v1/yanıtlar desteği sağlar",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Mesajlar",
|
||||
"aiCapabilityAnthropicMessagesDescription": "/v1/mesajlar desteği sağlar",
|
||||
"aiCapabilityV1Models": "Modeller Listesi",
|
||||
"aiCapabilityV1ModelsDescription": "T /v1/models model keşfini destekler",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini İçerik Üret",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Doğrudan Gemini API desteği sağlar",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Modeli Çağır",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "Eylem Günlükleri",
|
||||
"logRetention": "Kayıt Saklama",
|
||||
"logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın",
|
||||
"logRetentionDisabledWarningTitle": "Günlük Saklama Devre Dışı Bırakıldı",
|
||||
"logRetentionDisabledWarningDescription": "{logType} bu organizasyon için saklanmıyor, bu nedenle yeni etkinlikler burada görünmeyecek. Bu günlükleri toplamak için güvenlik ayarlarında saklamayı etkinleştirin.",
|
||||
"logRetentionDisabledWarningButton": "Güvenlik Ayarlarına Git",
|
||||
"requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin",
|
||||
"aiSessionLogs": "AI Ağ Geçidi Oturum Günlükleri",
|
||||
"aiSessionLogsDescription": "Bu organizasyondaki AI ağ geçidi isteklerinin istem ve yanıt transkriptlerini görüntüleyin",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.",
|
||||
"httpDestRequestLogsTitle": "HTTP İstek Günlükleri",
|
||||
"httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.",
|
||||
"httpDestAISessionLogsTitle": "AI Oturum Günlükleri",
|
||||
"httpDestAISessionLogsDescription": "AI geçidi istek ve yanıt oturumları, istemler, model yanıtları ve token kullanımı dahil.",
|
||||
"httpDestSaveChanges": "Değişiklikleri Kaydet",
|
||||
"httpDestCreateDestination": "Hedef Oluştur",
|
||||
"httpDestUpdatedSuccess": "Hedef başarıyla güncellendi",
|
||||
|
||||
+9
-1
@@ -1785,7 +1785,7 @@
|
||||
"aiClientConfigDescriptionClaude": "Anthropic 的终端代理编码工具。",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI 的终端代理编码工具。",
|
||||
"aiClientConfigDescriptionOpencode": "开源终端编码代理。",
|
||||
"aiClientConfigDescriptionCursor": "基于 VS Code 的 AI 代码编辑器。",
|
||||
"aiClientConfigDescriptionGemini": "Google的终端代理编码工具。",
|
||||
"aiClientConfigSetup": "设置",
|
||||
"aiClientConfigTabCli": "自动 (CLI)",
|
||||
"aiClientConfigTabManual": "手动配置",
|
||||
@@ -1891,6 +1891,7 @@
|
||||
"aiProviderRoutingModeTargetDescription": "通过您站点上的目标进行路由",
|
||||
"aiProviderRoutingModeTargetNote": "创建此提供商后,在“网络设置”选项卡中配置站点目标。",
|
||||
"aiProviderTargetNoOne": "该提供商没有任何目标。 添加目标以通过您的站点路由请求。",
|
||||
"aiProviderRemoteNodeTargetsWarning": "连接到远程节点的站点无法在AI网关供应商上被路由。",
|
||||
"aiProviderSkipTlsVerification": "跳过TLS验证",
|
||||
"aiProviderSkipTlsVerificationDescription": "禁用上游连接的TLS证书验证",
|
||||
"aiProviderBudget": "预算",
|
||||
@@ -1923,6 +1924,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "支持 /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic 消息",
|
||||
"aiCapabilityAnthropicMessagesDescription": "支持 /v1/messages",
|
||||
"aiCapabilityV1Models": "模型列表",
|
||||
"aiCapabilityV1ModelsDescription": "支持/v1/models模型发现",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini 生成内容",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "支持直接Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock 模型调用",
|
||||
@@ -3548,6 +3551,9 @@
|
||||
"sidebarLogsAction": "操作日志",
|
||||
"logRetention": "日志保留",
|
||||
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
|
||||
"logRetentionDisabledWarningTitle": "日志保留已禁用",
|
||||
"logRetentionDisabledWarningDescription": "{logType}未在此组织中被保留,因此新活动不会显示在此处。请在安全设置中启用日志保留以开始收集这些日志。",
|
||||
"logRetentionDisabledWarningButton": "转到安全设置",
|
||||
"requestLogsDescription": "查看此机构资源的详细请求日志",
|
||||
"aiSessionLogs": "AI 网关会话日志",
|
||||
"aiSessionLogsDescription": "查看此组织中AI网关请求的提示和响应记录",
|
||||
@@ -4081,6 +4087,8 @@
|
||||
"httpDestConnectionLogsDescription": "站点和隧道连接事件,包括连接和断开连接。",
|
||||
"httpDestRequestLogsTitle": "请求日志",
|
||||
"httpDestRequestLogsDescription": "HTTP 请求代理资源日志,包括方法、路径和响应代码。",
|
||||
"httpDestAISessionLogsTitle": "AI会话日志",
|
||||
"httpDestAISessionLogsDescription": "AI网关请求和响应会话,包括提示、模型响应和令牌使用。",
|
||||
"httpDestSaveChanges": "保存更改",
|
||||
"httpDestCreateDestination": "创建目标",
|
||||
"httpDestUpdatedSuccess": "目标已成功更新",
|
||||
|
||||
+788
-2213
File diff suppressed because it is too large
Load Diff
Generated
+3647
-2403
File diff suppressed because it is too large
Load Diff
+75
-75
@@ -32,49 +32,49 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1116.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-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.1.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-table": "9.1.2",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.19.0",
|
||||
"axios": "1.18.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -86,62 +86,62 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.6.2",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"glob": "13.0.6",
|
||||
"gpt-tokenizer": "^4.0.0",
|
||||
"helmet": "8.3.0",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.5.0",
|
||||
"ioredis": "6.0.0",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.3.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.33.0",
|
||||
"maxmind": "5.0.7",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.3.2",
|
||||
"next-intl": "4.13.7",
|
||||
"next": "16.2.11",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.5",
|
||||
"nodemailer": "9.0.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.50.0",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.86.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.5",
|
||||
"semver": "7.8.1",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.5.0",
|
||||
"stripe": "22.2.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.2",
|
||||
"uuid": "14.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.3",
|
||||
"ws": "8.21.0",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.4.3",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "2.21.0",
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.9.2",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.101.4",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -152,36 +152,36 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.9.0",
|
||||
"eslint-config-next": "16.3.2",
|
||||
"postcss": "8.5.26",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.2",
|
||||
"tsx": "4.23.12",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-eslint": "8.67.0"
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild": "0.28.0",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.26"
|
||||
"postcss": "8.5.15"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
@@ -468,6 +468,9 @@ export const eventStreamingDestinations = pgTable(
|
||||
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
|
||||
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
|
||||
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
|
||||
sendAISessionLogs: boolean("sendAISessionLogs")
|
||||
.notNull()
|
||||
.default(false),
|
||||
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
|
||||
config: text("config").notNull(), // JSON string with the configuration for the destination
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
|
||||
@@ -459,6 +459,9 @@ export const eventStreamingDestinations = sqliteTable(
|
||||
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sendAISessionLogs: integer("sendAISessionLogs", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
type: text("type").notNull(), // e.g. "http", "kafka", etc.
|
||||
config: text("config").notNull(), // JSON string with the configuration for the destination
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
export { AI_CAPABILITIES, type AiCapability };
|
||||
|
||||
export type AiCapabilityRoute = {
|
||||
method: "POST";
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
};
|
||||
|
||||
@@ -135,6 +135,21 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
v1_models: {
|
||||
id: "v1_models",
|
||||
protocolFamily: "anthropic",
|
||||
routes: [
|
||||
{ method: "GET", path: "/v1/models" },
|
||||
{ method: "GET", path: "/v1/models/:model" }
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
// Model listings are answered from the gateway's own view of the
|
||||
// provider allow/block lists rather than proxied upstream, so
|
||||
// there is never a stream to detect.
|
||||
isStreaming: () => false
|
||||
},
|
||||
gemini_generate_content: {
|
||||
id: "gemini_generate_content",
|
||||
protocolFamily: "google",
|
||||
|
||||
@@ -471,6 +471,8 @@ const REQUEST_NORMALIZERS: Record<
|
||||
openai_chat: normalizeOpenAiChatRequest,
|
||||
openai_responses: normalizeOpenAiResponsesRequest,
|
||||
anthropic_messages: normalizeAnthropicRequest,
|
||||
// Model discovery carries no transcript to normalize.
|
||||
v1_models: () => null,
|
||||
gemini_generate_content: normalizeGeminiRequest,
|
||||
google_generate_content: normalizeGeminiRequest,
|
||||
google_raw_predict: normalizeBestEffortRequest,
|
||||
@@ -485,6 +487,7 @@ const RESPONSE_NORMALIZERS: Record<
|
||||
openai_chat: normalizeOpenAiChatResponse,
|
||||
openai_responses: normalizeOpenAiResponsesResponse,
|
||||
anthropic_messages: normalizeAnthropicResponse,
|
||||
v1_models: () => null,
|
||||
gemini_generate_content: normalizeGeminiResponse,
|
||||
google_generate_content: normalizeGeminiResponse,
|
||||
google_raw_predict: normalizeGoogleRawPredictResponse,
|
||||
|
||||
@@ -44,6 +44,20 @@ export function getCatalogProviderForType(
|
||||
return PROVIDER_CATALOG_MAP[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-model feature flags as reported upstream. `null` means the catalog has
|
||||
* no data for that model - deliberately distinct from `false`, so consumers
|
||||
* can tell "unsupported" apart from "unknown".
|
||||
*/
|
||||
export type AiModelCapabilityFlags = {
|
||||
functionCalling: boolean | null;
|
||||
vision: boolean | null;
|
||||
promptCaching: boolean | null;
|
||||
reasoning: boolean | null;
|
||||
responseSchema: boolean | null;
|
||||
webSearch: boolean | null;
|
||||
};
|
||||
|
||||
export type AiModelCatalogEntry = {
|
||||
provider: CatalogProvider;
|
||||
model: string;
|
||||
@@ -53,8 +67,20 @@ export type AiModelCatalogEntry = {
|
||||
cache: number | null;
|
||||
reasoning: number | null;
|
||||
};
|
||||
limits: {
|
||||
/** Context window. */
|
||||
input: number | null;
|
||||
/** Cap on the output/max_tokens request parameter. */
|
||||
output: number | null;
|
||||
};
|
||||
capabilities: AiModelCapabilityFlags;
|
||||
};
|
||||
|
||||
const flag = z.boolean().nullable().optional();
|
||||
|
||||
// limits/capabilities are optional so a catalog published before they were
|
||||
// added (or an operator's own merge_file) still parses - those entries just
|
||||
// report unknown metadata rather than failing the whole payload.
|
||||
const catalogEntrySchema = z.object({
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
@@ -65,6 +91,22 @@ const catalogEntrySchema = z.object({
|
||||
cache: z.number().nullable().optional(),
|
||||
reasoning: z.number().nullable().optional()
|
||||
})
|
||||
.optional(),
|
||||
limits: z
|
||||
.object({
|
||||
input: z.number().nullable().optional(),
|
||||
output: z.number().nullable().optional()
|
||||
})
|
||||
.optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
functionCalling: flag,
|
||||
vision: flag,
|
||||
promptCaching: flag,
|
||||
reasoning: flag,
|
||||
responseSchema: flag,
|
||||
webSearch: flag
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
@@ -108,6 +150,18 @@ function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
|
||||
out: raw.pricing?.out ?? null,
|
||||
cache: raw.pricing?.cache ?? null,
|
||||
reasoning: raw.pricing?.reasoning ?? null
|
||||
},
|
||||
limits: {
|
||||
input: raw.limits?.input ?? null,
|
||||
output: raw.limits?.output ?? null
|
||||
},
|
||||
capabilities: {
|
||||
functionCalling: raw.capabilities?.functionCalling ?? null,
|
||||
vision: raw.capabilities?.vision ?? null,
|
||||
promptCaching: raw.capabilities?.promptCaching ?? null,
|
||||
reasoning: raw.capabilities?.reasoning ?? null,
|
||||
responseSchema: raw.capabilities?.responseSchema ?? null,
|
||||
webSearch: raw.capabilities?.webSearch ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -284,34 +338,44 @@ export class AiModelCatalog {
|
||||
|
||||
export const aiModelCatalog = new AiModelCatalog();
|
||||
|
||||
/**
|
||||
* Full catalog entries for a provider type, deduplicated by model id and
|
||||
* sorted by id. Model discovery uses these to report real token limits and
|
||||
* capability flags; `listCatalogModelsForType` is the id-only view of the
|
||||
* same list.
|
||||
*/
|
||||
export function listCatalogEntriesForType(
|
||||
type: AiProviderType,
|
||||
query?: string
|
||||
): AiModelCatalogEntry[] {
|
||||
const catalogProvider = getCatalogProviderForType(type);
|
||||
|
||||
let entries = catalogProvider ? aiModelCatalog.list(catalogProvider) : [];
|
||||
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
entries = entries.filter((e) => e.model.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
entries = entries.filter((e) => {
|
||||
if (seen.has(e.model)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(e.model);
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...entries].sort((a, b) => a.model.localeCompare(b.model));
|
||||
}
|
||||
|
||||
export function listCatalogModelsForType(
|
||||
type: AiProviderType,
|
||||
query?: string
|
||||
): { model: string }[] {
|
||||
const catalogProvider = getCatalogProviderForType(type);
|
||||
|
||||
let models = catalogProvider
|
||||
? aiModelCatalog.list(catalogProvider).map((entry) => ({
|
||||
model: entry.model
|
||||
}))
|
||||
: [];
|
||||
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
models = models.filter((m) => m.model.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
models = models.filter((m) => {
|
||||
if (seen.has(m.model)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(m.model);
|
||||
return true;
|
||||
});
|
||||
|
||||
models.sort((a, b) => a.model.localeCompare(b.model));
|
||||
return models;
|
||||
return listCatalogEntriesForType(type, query).map((entry) => ({
|
||||
model: entry.model
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
isAllowedByLists,
|
||||
isModelKeyPattern
|
||||
} from "@server/lib/aiModelKeyMatch";
|
||||
import type { AiModelCapabilityFlags } from "@server/lib/aiModelCatalog";
|
||||
|
||||
// Anthropic's Models API pagination: 20 per page by default, 1..1000.
|
||||
export const MODEL_PAGE_DEFAULT_LIMIT = 20;
|
||||
export const MODEL_PAGE_MAX_LIMIT = 1000;
|
||||
|
||||
// Release dates aren't something we can know for a wildcard allow pattern or a
|
||||
// catalog entry. The Models API explicitly permits an epoch value when the
|
||||
// release date is unknown.
|
||||
const UNKNOWN_CREATED_AT = new Date(0).toISOString();
|
||||
|
||||
/**
|
||||
* One entry of Anthropic's `GET /v1/models` response. Only the identity fields
|
||||
* can be filled in from a provider's model lists - token limits and
|
||||
* per-model capability flags aren't derivable from an allow/block list, and the
|
||||
* API schema declares all three nullable.
|
||||
*/
|
||||
export type AnthropicModelInfo = {
|
||||
type: "model";
|
||||
id: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
max_input_tokens: number | null;
|
||||
max_tokens: number | null;
|
||||
capabilities: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/** A model row an administrator configured explicitly on a provider. */
|
||||
export type ConfiguredModel = { name: string; createdAt: number };
|
||||
|
||||
/** What the pricing catalog knows about a model beyond its id. */
|
||||
export type CatalogModelMetadata = {
|
||||
maxInputTokens: number | null;
|
||||
maxOutputTokens: number | null;
|
||||
capabilities: AiModelCapabilityFlags;
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates the catalog's flat feature flags into the nested shape
|
||||
* Anthropic's Models API uses. Best-effort by nature: the catalog carries a
|
||||
* coarser set of flags than the Models API describes, so anything it reports
|
||||
* as unknown (`null`) is surfaced as unsupported rather than invented.
|
||||
*/
|
||||
export function capabilitiesFromCatalog(
|
||||
flags: AiModelCapabilityFlags
|
||||
): Record<string, unknown> {
|
||||
const supported = (value: boolean | null) => ({
|
||||
supported: value === true
|
||||
});
|
||||
// The catalog has a single `reasoning` flag and no way to distinguish
|
||||
// adaptive from budget_tokens-style thinking, so both variants follow it.
|
||||
const reasoning = flags.reasoning === true;
|
||||
|
||||
return {
|
||||
batch: supported(null),
|
||||
citations: supported(null),
|
||||
code_execution: supported(null),
|
||||
context_management: {
|
||||
supported: false,
|
||||
clear_thinking_20251015: null,
|
||||
clear_tool_uses_20250919: null,
|
||||
compact_20260112: null
|
||||
},
|
||||
effort: {
|
||||
supported: reasoning,
|
||||
low: supported(flags.reasoning),
|
||||
medium: supported(flags.reasoning),
|
||||
high: supported(flags.reasoning),
|
||||
max: supported(flags.reasoning),
|
||||
xhigh: null
|
||||
},
|
||||
image_input: supported(flags.vision),
|
||||
pdf_input: supported(null),
|
||||
structured_outputs: supported(flags.responseSchema),
|
||||
thinking: {
|
||||
supported: reasoning,
|
||||
types: {
|
||||
adaptive: { supported: reasoning },
|
||||
enabled: { supported: reasoning }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One attached provider's contribution to a resource's model listing, with the
|
||||
* allow/block lists already resolved for the attachment's access mode.
|
||||
*/
|
||||
export type ModelDiscoveryProvider = {
|
||||
providerId: number;
|
||||
allows: string[];
|
||||
blocks: string[];
|
||||
/**
|
||||
* Concrete model ids the provider's type is known to serve, with whatever
|
||||
* the catalog knows about each. This is what lets a wildcard allow such as
|
||||
* `claude-*` enumerate into real ids; provider types with no catalog
|
||||
* (aggregators, custom) pass an empty map and surface only their exact
|
||||
* allow entries.
|
||||
*/
|
||||
catalog: Map<string, CatalogModelMetadata>;
|
||||
/** Keyed by model key, for display names and creation times. */
|
||||
configured: Map<string, ConfiguredModel>;
|
||||
};
|
||||
|
||||
export type ModelPage = {
|
||||
data: AnthropicModelInfo[];
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Expands one provider's effective allow/block lists into concrete model ids.
|
||||
* Two sources feed the candidate set: exact (non-wildcard) allow entries, which
|
||||
* are already concrete ids, and the catalog for the provider's type, which is
|
||||
* what makes wildcard allows enumerable. Every candidate is then run back
|
||||
* through the same allow/block check the inference pipeline applies, so a block
|
||||
* pattern hides a model here exactly as it would reject it at request time.
|
||||
*/
|
||||
export function expandProviderModels(
|
||||
provider: ModelDiscoveryProvider
|
||||
): AnthropicModelInfo[] {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
for (const allow of provider.allows) {
|
||||
if (!isModelKeyPattern(allow)) {
|
||||
candidates.add(allow);
|
||||
}
|
||||
}
|
||||
for (const modelId of provider.catalog.keys()) {
|
||||
candidates.add(modelId);
|
||||
}
|
||||
|
||||
const models: AnthropicModelInfo[] = [];
|
||||
for (const modelKey of candidates) {
|
||||
if (!isAllowedByLists(modelKey, provider.allows, provider.blocks)) {
|
||||
continue;
|
||||
}
|
||||
const configured = provider.configured.get(modelKey);
|
||||
const catalog = provider.catalog.get(modelKey);
|
||||
|
||||
models.push({
|
||||
type: "model",
|
||||
id: modelKey,
|
||||
display_name: configured?.name || modelKey,
|
||||
created_at: configured
|
||||
? new Date(configured.createdAt).toISOString()
|
||||
: UNKNOWN_CREATED_AT,
|
||||
max_input_tokens: catalog?.maxInputTokens ?? null,
|
||||
max_tokens: catalog?.maxOutputTokens ?? null,
|
||||
capabilities: catalog
|
||||
? capabilitiesFromCatalog(catalog.capabilities)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the permitted models across every provider attached to a
|
||||
* resource. Unlike an inference request there is no requested model to
|
||||
* disambiguate on, so no provider selection happens - the listing is the union
|
||||
* of what each provider would accept, deduplicated by model id.
|
||||
*/
|
||||
export function listPermittedModels(
|
||||
providers: ModelDiscoveryProvider[]
|
||||
): AnthropicModelInfo[] {
|
||||
const byModelId = new Map<string, AnthropicModelInfo>();
|
||||
|
||||
// Sorted so a model offered by two providers always resolves to the same
|
||||
// entry, which keeps the cursor ordering stable across requests.
|
||||
const ordered = [...providers].sort((a, b) => a.providerId - b.providerId);
|
||||
|
||||
for (const provider of ordered) {
|
||||
for (const model of expandProviderModels(provider)) {
|
||||
if (!byModelId.has(model.id)) {
|
||||
byModelId.set(model.id, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "More recently released models are listed first" per the Models API,
|
||||
// with the id as a tie-break so the ordering is total - cursor pagination
|
||||
// needs it to be stable between calls.
|
||||
return [...byModelId.values()].sort((a, b) => {
|
||||
const byCreated = b.created_at.localeCompare(a.created_at);
|
||||
return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies Anthropic's cursor pagination to an ordered model list. `after_id`
|
||||
* returns the page immediately after that model, `before_id` the page
|
||||
* immediately before it. Returns an error message for a caller mistake
|
||||
* (both cursors, or a cursor naming a model that isn't in the list).
|
||||
*/
|
||||
export function paginateModels(
|
||||
models: AnthropicModelInfo[],
|
||||
limit: number,
|
||||
cursor: { afterId?: string; beforeId?: string }
|
||||
): ModelPage | { error: string } {
|
||||
if (cursor.afterId && cursor.beforeId) {
|
||||
return { error: "Only one of after_id and before_id may be provided" };
|
||||
}
|
||||
|
||||
const cursorId = cursor.afterId ?? cursor.beforeId;
|
||||
if (!cursorId) {
|
||||
return {
|
||||
data: models.slice(0, limit),
|
||||
has_more: models.length > limit
|
||||
};
|
||||
}
|
||||
|
||||
const index = models.findIndex((model) => model.id === cursorId);
|
||||
if (index === -1) {
|
||||
return { error: `Unknown cursor id "${cursorId}"` };
|
||||
}
|
||||
|
||||
if (cursor.afterId) {
|
||||
const start = index + 1;
|
||||
return {
|
||||
data: models.slice(start, start + limit),
|
||||
has_more: models.length > start + limit
|
||||
};
|
||||
}
|
||||
|
||||
const start = Math.max(0, index - limit);
|
||||
return {
|
||||
data: models.slice(start, index),
|
||||
has_more: start > 0
|
||||
};
|
||||
}
|
||||
@@ -335,6 +335,8 @@ const EXTRACTORS: Record<
|
||||
openai_chat: extractOpenAiChat,
|
||||
openai_responses: extractOpenAiResponses,
|
||||
anthropic_messages: extractAnthropicMessages,
|
||||
// Model discovery never runs a model, so there are no tokens to bill.
|
||||
v1_models: () => null,
|
||||
gemini_generate_content: extractGoogleGenerateContent,
|
||||
google_generate_content: extractGoogleGenerateContent,
|
||||
// rawPredict is a passthrough to whatever the underlying publisher
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum TierFeature {
|
||||
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||
ConnectionLogs = "connectionLogs",
|
||||
AISessionLogs = "aiSessionLogs",
|
||||
RotateCredentials = "rotateCredentials",
|
||||
MaintenancePage = "maintenancePage", // handle downgrade
|
||||
DevicePosture = "devicePosture",
|
||||
@@ -37,6 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.AISessionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||
|
||||
@@ -22,7 +22,10 @@ export async function listExitNodes(
|
||||
// Accepted for parity with the enterprise implementation (used there for
|
||||
// site-label filtering of remote exit nodes). The OSS build has no remote
|
||||
// exit nodes, so it is unused here.
|
||||
siteId?: number
|
||||
siteId?: number,
|
||||
// Same as above: accepted for parity, unused since the OSS build has no
|
||||
// remote exit nodes to exclude.
|
||||
noRemote = false
|
||||
) {
|
||||
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
||||
const allExitNodes = await db
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { db, exitNodes, Transaction } from "@server/db";
|
||||
import { db, exitNodes, exitNodeOrgs, Transaction } from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Reserves the next available exit node subnet.
|
||||
*
|
||||
* Exit node subnets must never overlap with one another - regardless of
|
||||
* which org(s) they belong to - since HA exit nodes can end up routing for
|
||||
* the same org. This acquires a lock that the caller MUST release (via the
|
||||
* returned `release`) only after the chosen address has been durably
|
||||
* persisted (e.g. after the enclosing transaction commits), otherwise
|
||||
* concurrent callers can race and pick the same subnet.
|
||||
* There isn't enough address space to give every exit node in every org a
|
||||
* globally unique subnet, so we only guarantee uniqueness among exit nodes
|
||||
* that already belong to the same org - that's all that actually matters,
|
||||
* since HA only routes multiple exit nodes for a single org. Pass `orgId` to
|
||||
* scope the search to that org's existing exit nodes; without it, the search
|
||||
* considers every exit node (used by flows with no org context, e.g. the
|
||||
* initial gerbil exit node bootstrap). This acquires a lock that the caller
|
||||
* MUST release (via the returned `release`) only after the chosen address
|
||||
* has been durably persisted (e.g. after the enclosing transaction commits),
|
||||
* otherwise concurrent callers can race and pick the same subnet.
|
||||
*/
|
||||
export async function getNextAvailableSubnet(
|
||||
trx: Transaction | typeof db = db
|
||||
trx: Transaction | typeof db = db,
|
||||
orgId?: string
|
||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||
const lockKey = "exit-node-subnet-allocation";
|
||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||
@@ -24,12 +30,19 @@ export async function getNextAvailableSubnet(
|
||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||
|
||||
try {
|
||||
// Get all existing subnets from routes table
|
||||
const existingAddresses = await trx
|
||||
.select({
|
||||
address: exitNodes.address
|
||||
})
|
||||
.from(exitNodes);
|
||||
// Get existing subnets, scoped to this org's exit nodes when known
|
||||
const existingAddresses = orgId
|
||||
? await trx
|
||||
.select({ address: exitNodes.address })
|
||||
.from(exitNodes)
|
||||
.innerJoin(
|
||||
exitNodeOrgs,
|
||||
eq(exitNodeOrgs.exitNodeId, exitNodes.exitNodeId)
|
||||
)
|
||||
.where(eq(exitNodeOrgs.orgId, orgId))
|
||||
: await trx
|
||||
.select({ address: exitNodes.address })
|
||||
.from(exitNodes);
|
||||
|
||||
const addresses = existingAddresses.map((a) => a.address);
|
||||
let subnet = findNextAvailableCidr(
|
||||
|
||||
@@ -348,7 +348,6 @@ export const configSchema = z
|
||||
.optional()
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
use_subdomain: z.boolean().optional().default(false),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||
block_size: z.number().positive().gt(0).optional().default(24),
|
||||
site_block_size: z
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, userOrgRoles, users } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import type {
|
||||
|
||||
@@ -153,7 +153,8 @@ export async function listExitNodes(
|
||||
orgId: string,
|
||||
filterOnline = false,
|
||||
noCloud = false,
|
||||
siteId?: number
|
||||
siteId?: number,
|
||||
noRemote = false
|
||||
) {
|
||||
const allExitNodes = await db
|
||||
.select({
|
||||
@@ -242,7 +243,9 @@ export async function listExitNodes(
|
||||
|
||||
let remoteExitNodesList = allExitNodes.filter(
|
||||
(node) =>
|
||||
node.type === "remoteExitNode" && (!filterOnline || node.online)
|
||||
node.type === "remoteExitNode" &&
|
||||
!noRemote &&
|
||||
(!filterOnline || node.online)
|
||||
);
|
||||
const gerbilExitNodes = allExitNodes.filter(
|
||||
(node) =>
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
requestAuditLog,
|
||||
actionAuditLog,
|
||||
accessAuditLog,
|
||||
connectionAuditLog
|
||||
connectionAuditLog,
|
||||
aiSessionLog
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
|
||||
@@ -309,6 +310,7 @@ export class LogStreamingManager {
|
||||
if (dest.sendActionLogs) enabledTypes.push("action");
|
||||
if (dest.sendAccessLogs) enabledTypes.push("access");
|
||||
if (dest.sendConnectionLogs) enabledTypes.push("connection");
|
||||
if (dest.sendAISessionLogs) enabledTypes.push("aiSession");
|
||||
|
||||
if (enabledTypes.length === 0) return;
|
||||
|
||||
@@ -585,6 +587,13 @@ export class LogStreamingManager {
|
||||
.where(eq(connectionAuditLog.orgId, orgId));
|
||||
return row?.maxId ?? 0;
|
||||
}
|
||||
case "aiSession": {
|
||||
const [row] = await logsDb
|
||||
.select({ maxId: max(aiSessionLog.id) })
|
||||
.from(aiSessionLog)
|
||||
.where(eq(aiSessionLog.orgId, orgId));
|
||||
return row?.maxId ?? 0;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -670,6 +679,21 @@ export class LogStreamingManager {
|
||||
.limit(limit)) as Array<
|
||||
Record<string, unknown> & { id: number }
|
||||
>;
|
||||
|
||||
case "aiSession":
|
||||
return (await logsDb
|
||||
.select()
|
||||
.from(aiSessionLog)
|
||||
.where(
|
||||
and(
|
||||
eq(aiSessionLog.orgId, orgId),
|
||||
gt(aiSessionLog.id, afterId)
|
||||
)
|
||||
)
|
||||
.orderBy(aiSessionLog.id)
|
||||
.limit(limit)) as Array<
|
||||
Record<string, unknown> & { id: number }
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +718,14 @@ export class LogStreamingManager {
|
||||
timestamp =
|
||||
typeof row.startedAt === "number" ? row.startedAt : 0;
|
||||
break;
|
||||
case "aiSession":
|
||||
// createdAt is stored as epoch milliseconds; normalise to
|
||||
// epoch seconds to match the other log types.
|
||||
timestamp =
|
||||
typeof row.createdAt === "number"
|
||||
? Math.floor(row.createdAt / 1000)
|
||||
: 0;
|
||||
break;
|
||||
}
|
||||
|
||||
const orgId = typeof row.orgId === "string" ? row.orgId : "";
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
// Log type identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LogType = "request" | "action" | "access" | "connection";
|
||||
export type LogType = "request" | "action" | "access" | "connection" | "aiSession";
|
||||
|
||||
export const LOG_TYPES: LogType[] = [
|
||||
"request",
|
||||
"action",
|
||||
"access",
|
||||
"connection"
|
||||
"connection",
|
||||
"aiSession"
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
|
||||
import type { InferInsertModel } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "#private/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
normalizeAiRequest,
|
||||
normalizeAiResponse
|
||||
} from "@server/lib/aiMessageNormalization";
|
||||
|
||||
// Caps how much of the request/response body we keep per row, so a single
|
||||
// huge multimodal payload can't blow up buffer memory or storage.
|
||||
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
|
||||
|
||||
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
|
||||
|
||||
// In-memory buffer for batching AI session log inserts, mirroring the
|
||||
// approach in server/routers/badger/logRequestAudit.ts.
|
||||
const sessionLogBuffer: AiSessionLogInsert[] = [];
|
||||
|
||||
const BATCH_SIZE = 100; // Write to DB every 100 logs
|
||||
const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first
|
||||
const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let isFlushInProgress = false;
|
||||
|
||||
/**
|
||||
* Flush buffered logs to database
|
||||
*/
|
||||
async function flushSessionLogs() {
|
||||
if (sessionLogBuffer.length === 0 || isFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFlushInProgress = true;
|
||||
|
||||
// Take all current logs and clear buffer
|
||||
const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length);
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all inserts succeed or fail together
|
||||
await logsDb.transaction(async (tx) => {
|
||||
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
||||
const BATCH_DB_SIZE = 25;
|
||||
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
||||
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
||||
await tx.insert(aiSessionLog).values(batch);
|
||||
}
|
||||
});
|
||||
logger.debug(
|
||||
`Flushed ${logsToWrite.length} AI session logs to database`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error flushing AI session logs:", error);
|
||||
// On transaction error, put logs back at the front of the buffer to retry
|
||||
// but only if buffer isn't too large
|
||||
if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
|
||||
sessionLogBuffer.unshift(...logsToWrite);
|
||||
logger.info(
|
||||
`Re-queued ${logsToWrite.length} AI session logs for retry`
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Buffer full, dropped ${logsToWrite.length} AI session logs`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isFlushInProgress = false;
|
||||
// If buffer filled up while we were flushing, flush again
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in follow-up AI session log flush:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush if not already scheduled
|
||||
*/
|
||||
function scheduleFlush() {
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in scheduled AI session log flush:", err)
|
||||
);
|
||||
}, BATCH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully flush all pending logs (call this on shutdown)
|
||||
*/
|
||||
export async function shutdownAiSessionLogger() {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
// Force flush even if one is in progress by waiting and retrying
|
||||
while (isFlushInProgress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await flushSessionLogs();
|
||||
}
|
||||
|
||||
async function getRetentionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = await cache.get<number>(`org_${orgId}_aiSessionsDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAISessions:
|
||||
orgs.settingsLogRetentionDaysAISessions
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
await cache.set(
|
||||
`org_${orgId}_aiSessionsDays`,
|
||||
org.settingsLogRetentionDaysAISessions,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysAISessions;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
|
||||
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
|
||||
// match aiUsageRecords - convert before comparing.
|
||||
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
|
||||
|
||||
try {
|
||||
await logsDb
|
||||
.delete(aiSessionLog)
|
||||
.where(
|
||||
and(
|
||||
lt(aiSessionLog.createdAt, cutoffTimestampMs),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old AI session logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBody(value: string): { value: string; truncated: boolean } {
|
||||
if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) {
|
||||
return { value, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
export function logAiSession(data: {
|
||||
sessionId: string;
|
||||
capability: AiCapability;
|
||||
provider: AiProvider;
|
||||
requestedModel: string | undefined;
|
||||
requestBody: unknown;
|
||||
responseText: string;
|
||||
isStream: boolean;
|
||||
statusCode: number;
|
||||
orgId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
requestUserId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
}): void {
|
||||
(async () => {
|
||||
try {
|
||||
// Check retention before buffering any logs
|
||||
if (data.orgId) {
|
||||
const retentionDays = await getRetentionDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No org resolved for this request - nothing to govern
|
||||
// retention with, so don't log it.
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBodyText = truncateBody(
|
||||
JSON.stringify(data.requestBody ?? "")
|
||||
);
|
||||
const responseBodyText = truncateBody(data.responseText ?? "");
|
||||
|
||||
// Uniform, capability-agnostic transcript for search/display -
|
||||
// computed from the untruncated originals so normalization sees
|
||||
// the full content; the normalized result gets its own
|
||||
// (typically much smaller) truncation pass below.
|
||||
const normalizedRequestMessages = normalizeAiRequest(
|
||||
data.capability,
|
||||
data.requestBody
|
||||
);
|
||||
const normalizedResponseMessages = normalizeAiResponse(
|
||||
data.capability,
|
||||
data.responseText ?? "",
|
||||
data.isStream
|
||||
);
|
||||
const normalizedRequestText = normalizedRequestMessages
|
||||
? truncateBody(JSON.stringify(normalizedRequestMessages))
|
||||
: null;
|
||||
const normalizedResponseText = normalizedResponseMessages
|
||||
? truncateBody(JSON.stringify(normalizedResponseMessages))
|
||||
: null;
|
||||
|
||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
||||
logger.warn(
|
||||
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
providerId: data.provider.providerId,
|
||||
capability: data.capability,
|
||||
resourceId: data.resourceId ?? undefined,
|
||||
siteResourceId: data.siteResourceId ?? undefined,
|
||||
userId: sanitizeString(data.requestUserId ?? undefined),
|
||||
virtualApiKeyId: sanitizeString(
|
||||
data.virtualApiKeyId ?? undefined
|
||||
),
|
||||
requestedModel: sanitizeString(data.requestedModel),
|
||||
isStream: data.isStream,
|
||||
requestBody: sanitizeString(requestBodyText.value),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
responseBodyText.truncated ||
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
statusCode: data.statusCode,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error flushing AI session logs:", err)
|
||||
);
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to log AI session", { error });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -291,6 +291,10 @@ async function disableFeature(
|
||||
await disableConnectionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AISessionLogs:
|
||||
await disableAISessionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.RotateCredentials:
|
||||
await disableRotateCredentials(orgId);
|
||||
break;
|
||||
@@ -493,6 +497,15 @@ async function disableConnectionLogs(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled connection logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAISessionLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAISessions: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled AI session logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableRotateCredentials(orgId: string): Promise<void> {}
|
||||
|
||||
async function disablemaintenancePage(orgId: string): Promise<void> {
|
||||
|
||||
@@ -37,7 +37,8 @@ const bodySchema = z.strictObject({
|
||||
sendConnectionLogs: z.boolean().optional().default(false),
|
||||
sendRequestLogs: z.boolean().optional().default(false),
|
||||
sendActionLogs: z.boolean().optional().default(false),
|
||||
sendAccessLogs: z.boolean().optional().default(false)
|
||||
sendAccessLogs: z.boolean().optional().default(false),
|
||||
sendAISessionLogs: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export type CreateEventStreamingDestinationResponse = {
|
||||
@@ -122,7 +123,8 @@ export async function createEventStreamingDestination(
|
||||
sendAccessLogs: parsedBody.data.sendAccessLogs,
|
||||
sendActionLogs: parsedBody.data.sendActionLogs,
|
||||
sendConnectionLogs: parsedBody.data.sendConnectionLogs,
|
||||
sendRequestLogs: parsedBody.data.sendRequestLogs
|
||||
sendRequestLogs: parsedBody.data.sendRequestLogs,
|
||||
sendAISessionLogs: parsedBody.data.sendAISessionLogs
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export type ListEventStreamingDestinationsResponse = {
|
||||
sendRequestLogs: boolean;
|
||||
sendActionLogs: boolean;
|
||||
sendAccessLogs: boolean;
|
||||
sendAISessionLogs: boolean;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
@@ -83,7 +84,8 @@ const ListEventStreamingDestinationsResponseDataSchema = z.object({
|
||||
sendConnectionLogs: z.boolean(),
|
||||
sendRequestLogs: z.boolean(),
|
||||
sendActionLogs: z.boolean(),
|
||||
sendAccessLogs: z.boolean()
|
||||
sendAccessLogs: z.boolean(),
|
||||
sendAISessionLogs: z.boolean()
|
||||
})
|
||||
),
|
||||
pagination: z.object({
|
||||
|
||||
@@ -40,7 +40,8 @@ const bodySchema = z.strictObject({
|
||||
sendConnectionLogs: z.boolean().optional(),
|
||||
sendRequestLogs: z.boolean().optional(),
|
||||
sendActionLogs: z.boolean().optional(),
|
||||
sendAccessLogs: z.boolean().optional()
|
||||
sendAccessLogs: z.boolean().optional(),
|
||||
sendAISessionLogs: z.boolean().optional()
|
||||
});
|
||||
|
||||
export type UpdateEventStreamingDestinationResponse = {
|
||||
@@ -125,7 +126,7 @@ export async function updateEventStreamingDestination(
|
||||
);
|
||||
}
|
||||
|
||||
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
||||
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs, sendAISessionLogs } = parsedBody.data;
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: Date.now()
|
||||
@@ -141,6 +142,7 @@ export async function updateEventStreamingDestination(
|
||||
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;
|
||||
if (sendConnectionLogs !== undefined) updateData.sendConnectionLogs = sendConnectionLogs;
|
||||
if (sendRequestLogs !== undefined) updateData.sendRequestLogs = sendRequestLogs;
|
||||
if (sendAISessionLogs !== undefined) updateData.sendAISessionLogs = sendAISessionLogs;
|
||||
|
||||
await db
|
||||
.update(eventStreamingDestinations)
|
||||
|
||||
@@ -21,6 +21,10 @@ import * as auth from "#private/routers/auth";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as generateLicense from "#private/routers/generatedLicense";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import {
|
||||
queryAiSessionLogs,
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
@@ -591,6 +595,25 @@ authenticated.get(
|
||||
logs.exportConnectionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
|
||||
@@ -34,10 +34,6 @@ export async function createExitNode(
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
@@ -48,7 +44,7 @@ export async function createExitNode(
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
||||
address,
|
||||
listenPort,
|
||||
online: true,
|
||||
|
||||
@@ -43,6 +43,10 @@ import {
|
||||
unauthenticated as ua,
|
||||
authenticated as a
|
||||
} from "@server/routers/integration";
|
||||
import {
|
||||
queryAiSessionLogs,
|
||||
exportAiSessionLogs
|
||||
} from "@server/routers/auditLogs";
|
||||
import { logActionAudit } from "#private/middlewares";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
@@ -153,6 +157,25 @@ authenticated.get(
|
||||
logs.exportConnectionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.aiSessionLogs),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { inflate } from "zlib";
|
||||
import { promisify } from "util";
|
||||
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
|
||||
export async function flushRequestLogToDb(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const zlibInflate = promisify(inflate);
|
||||
|
||||
interface HTTPRequestLogData {
|
||||
requestId: string;
|
||||
resourceId: number; // siteResourceId
|
||||
timestamp: string; // ISO 8601
|
||||
method: string;
|
||||
scheme: string; // "http" or "https"
|
||||
host: string;
|
||||
path: string;
|
||||
rawQuery?: string;
|
||||
userAgent?: string;
|
||||
sourceAddr: string; // ip:port
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||
*/
|
||||
async function decompressRequestLog(
|
||||
compressed: string
|
||||
): Promise<HTTPRequestLogData[]> {
|
||||
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||
const decompressed = await zlibInflate(compressedBuffer);
|
||||
const jsonString = decompressed.toString("utf-8");
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Decompressed request log data is not an array");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
const { message, client } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Request log received but no newt client in context");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Request log received but newt has no siteId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.data?.compressed) {
|
||||
logger.warn("Request log message missing compressed data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
|
||||
if (!site) {
|
||||
logger.warn(
|
||||
`Request log received but site ${newt.siteId} not found in database`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysRequest === 0) {
|
||||
logger.debug(
|
||||
`Request log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: HTTPRequestLogData[];
|
||||
try {
|
||||
entries = await decompressRequestLog(message.data.compressed);
|
||||
} catch (error) {
|
||||
logger.error("Failed to decompress request log data:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
||||
|
||||
// Build a map from sourceIp → external endpoint string by joining clients
|
||||
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
|
||||
// of the client device and is used for GeoIP lookup.
|
||||
const ipToEndpoint = new Map<string, string>();
|
||||
|
||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||
: null;
|
||||
|
||||
if (cidrSuffix) {
|
||||
const uniqueSourceAddrs = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.sourceAddr) {
|
||||
uniqueSourceAddrs.add(entry.sourceAddr);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueSourceAddrs.size > 0) {
|
||||
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
|
||||
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||
return `${ip}${cidrSuffix}`;
|
||||
});
|
||||
|
||||
const matchedClients = await db
|
||||
.select({
|
||||
subnet: clients.subnet,
|
||||
endpoint: clientSitesAssociationsCache.endpoint
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSitesAssociationsCache,
|
||||
and(
|
||||
eq(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clients.clientId
|
||||
),
|
||||
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.orgId, orgId),
|
||||
inArray(clients.subnet, subnetQueries)
|
||||
)
|
||||
);
|
||||
|
||||
for (const c of matchedClients) {
|
||||
if (c.endpoint) {
|
||||
const ip = c.subnet.split("/")[0];
|
||||
ipToEndpoint.set(ip, c.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.requestId ||
|
||||
!entry.resourceId ||
|
||||
!entry.method ||
|
||||
!entry.scheme ||
|
||||
!entry.host ||
|
||||
!entry.path ||
|
||||
!entry.sourceAddr
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRequestURL =
|
||||
entry.scheme +
|
||||
"://" +
|
||||
entry.host +
|
||||
entry.path +
|
||||
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
||||
|
||||
// Resolve the client's external endpoint for GeoIP lookup.
|
||||
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
|
||||
const sourceIp = entry.sourceAddr.includes(":")
|
||||
? entry.sourceAddr.split(":")[0]
|
||||
: entry.sourceAddr;
|
||||
const endpoint = ipToEndpoint.get(sourceIp);
|
||||
let location: string | undefined;
|
||||
if (endpoint) {
|
||||
const endpointIp = endpoint.includes(":")
|
||||
? endpoint.split(":")[0]
|
||||
: endpoint;
|
||||
location = await getCountryCodeForIp(endpointIp);
|
||||
}
|
||||
|
||||
await logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 108,
|
||||
siteResourceId: entry.resourceId,
|
||||
orgId,
|
||||
location
|
||||
},
|
||||
{
|
||||
path: entry.path,
|
||||
originalRequestURL,
|
||||
scheme: entry.scheme,
|
||||
host: entry.host,
|
||||
method: entry.method,
|
||||
tls: entry.tls,
|
||||
requestIp: entry.sourceAddr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
|
||||
);
|
||||
};
|
||||
@@ -12,4 +12,3 @@
|
||||
*/
|
||||
|
||||
export * from "./handleConnectionLogMessage";
|
||||
export * from "./handleRequestLogMessage";
|
||||
|
||||
@@ -191,13 +191,20 @@ export async function createRemoteExitNode(
|
||||
|
||||
// If this remote exit node isn't already backing an exit node in
|
||||
// another org, we're about to create a brand new one. Reserve a
|
||||
// subnet for it up front so the allocation lock is held across the
|
||||
// whole insert - this guarantees exit node subnets never overlap,
|
||||
// even under concurrent creation, which matters for HA setups.
|
||||
// subnet for it up front, scoped to this org's existing exit nodes,
|
||||
// so the allocation lock is held across the whole insert - this
|
||||
// guarantees exit node subnets never overlap within the org, even
|
||||
// under concurrent creation, which matters for HA setups. Subnets
|
||||
// may still be reused across different orgs; there isn't enough
|
||||
// address space to avoid that, and it isn't necessary since HA only
|
||||
// routes multiple exit nodes for the same org.
|
||||
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
||||
let newExitNodeAddress: string | null = null;
|
||||
if (!existingExitNode) {
|
||||
const { value, release } = await getNextAvailableSubnet();
|
||||
const { value, release } = await getNextAvailableSubnet(
|
||||
db,
|
||||
orgId
|
||||
);
|
||||
newExitNodeAddress = value;
|
||||
releaseSubnetLock = release;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@ import {
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
handleConnectionLogMessage,
|
||||
handleRequestLogMessage
|
||||
} from "#private/routers/newt";
|
||||
|
||||
export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
|
||||
"newt/access-log": handleConnectionLogMessage,
|
||||
"newt/request-log": handleRequestLogMessage
|
||||
};
|
||||
|
||||
@@ -139,7 +139,7 @@ const processMessage = async (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Message handling error:", error);
|
||||
logger.warn("Message handling error:", error);
|
||||
// ws.send(JSON.stringify({
|
||||
// type: "error",
|
||||
// data: {
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import {
|
||||
AI_CAPABILITY_DEFS,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
|
||||
import { handleV1Models } from "@server/routers/aiGateway";
|
||||
|
||||
type CapabilityHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
capability: AiCapability
|
||||
) => Promise<any>;
|
||||
|
||||
// Capabilities the gateway answers itself instead of proxying upstream.
|
||||
// Everything else goes through the inference pipeline.
|
||||
const LOCAL_HANDLERS: Partial<Record<AiCapability, CapabilityHandler>> = {
|
||||
v1_models: handleV1Models
|
||||
};
|
||||
|
||||
export function createAiGatewayRouter() {
|
||||
const router = Router();
|
||||
|
||||
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||
const capability = def.id as AiCapability;
|
||||
const handler = LOCAL_HANDLERS[capability] ?? handleAiGatewayProxy;
|
||||
for (const route of def.routes) {
|
||||
router.post(route.path, (req, res) =>
|
||||
handleAiGatewayProxy(req, res, capability)
|
||||
);
|
||||
const bind = (req: Request, res: Response) =>
|
||||
handler(req, res, capability);
|
||||
if (route.method === "GET") {
|
||||
router.get(route.path, bind);
|
||||
} else {
|
||||
router.post(route.path, bind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { handleAiGatewayProxy } from "./pipeline";
|
||||
export { handleV1Models } from "./v1Models";
|
||||
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||
|
||||
@@ -1,171 +1,12 @@
|
||||
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
|
||||
import type { InferInsertModel } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
normalizeAiRequest,
|
||||
normalizeAiResponse
|
||||
} from "@server/lib/aiMessageNormalization";
|
||||
|
||||
// Caps how much of the request/response body we keep per row, so a single
|
||||
// huge multimodal payload can't blow up buffer memory or storage.
|
||||
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
|
||||
|
||||
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
|
||||
|
||||
// In-memory buffer for batching AI session log inserts, mirroring the
|
||||
// approach in server/routers/badger/logRequestAudit.ts.
|
||||
const sessionLogBuffer: AiSessionLogInsert[] = [];
|
||||
|
||||
const BATCH_SIZE = 100; // Write to DB every 100 logs
|
||||
const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first
|
||||
const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let isFlushInProgress = false;
|
||||
|
||||
/**
|
||||
* Flush buffered logs to database
|
||||
*/
|
||||
async function flushSessionLogs() {
|
||||
if (sessionLogBuffer.length === 0 || isFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFlushInProgress = true;
|
||||
|
||||
// Take all current logs and clear buffer
|
||||
const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length);
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all inserts succeed or fail together
|
||||
await logsDb.transaction(async (tx) => {
|
||||
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
||||
const BATCH_DB_SIZE = 25;
|
||||
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
||||
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
||||
await tx.insert(aiSessionLog).values(batch);
|
||||
}
|
||||
});
|
||||
logger.debug(
|
||||
`Flushed ${logsToWrite.length} AI session logs to database`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error flushing AI session logs:", error);
|
||||
// On transaction error, put logs back at the front of the buffer to retry
|
||||
// but only if buffer isn't too large
|
||||
if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
|
||||
sessionLogBuffer.unshift(...logsToWrite);
|
||||
logger.info(
|
||||
`Re-queued ${logsToWrite.length} AI session logs for retry`
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Buffer full, dropped ${logsToWrite.length} AI session logs`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isFlushInProgress = false;
|
||||
// If buffer filled up while we were flushing, flush again
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in follow-up AI session log flush:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush if not already scheduled
|
||||
*/
|
||||
function scheduleFlush() {
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error in scheduled AI session log flush:", err)
|
||||
);
|
||||
}, BATCH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
import { AiCapability } from "@app/lib/aiCapabilities";
|
||||
import { AiProvider } from "@server/db";
|
||||
|
||||
/**
|
||||
* Gracefully flush all pending logs (call this on shutdown)
|
||||
*/
|
||||
export async function shutdownAiSessionLogger() {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
// Force flush even if one is in progress by waiting and retrying
|
||||
while (isFlushInProgress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await flushSessionLogs();
|
||||
}
|
||||
export async function shutdownAiSessionLogger() {}
|
||||
|
||||
async function getRetentionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = await cache.get<number>(`org_${orgId}_aiSessionsDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAISessions:
|
||||
orgs.settingsLogRetentionDaysAISessions
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
await cache.set(
|
||||
`org_${orgId}_aiSessionsDays`,
|
||||
org.settingsLogRetentionDaysAISessions,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysAISessions;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
|
||||
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
|
||||
// match aiUsageRecords - convert before comparing.
|
||||
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
|
||||
|
||||
try {
|
||||
await logsDb
|
||||
.delete(aiSessionLog)
|
||||
.where(
|
||||
and(
|
||||
lt(aiSessionLog.createdAt, cutoffTimestampMs),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old AI session logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBody(value: string): { value: string; truncated: boolean } {
|
||||
if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) {
|
||||
return { value, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {}
|
||||
|
||||
export function logAiSession(data: {
|
||||
sessionId: string;
|
||||
@@ -181,95 +22,4 @@ export function logAiSession(data: {
|
||||
siteResourceId: number | null;
|
||||
requestUserId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
}): void {
|
||||
(async () => {
|
||||
try {
|
||||
// Check retention before buffering any logs
|
||||
if (data.orgId) {
|
||||
const retentionDays = await getRetentionDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No org resolved for this request - nothing to govern
|
||||
// retention with, so don't log it.
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBodyText = truncateBody(
|
||||
JSON.stringify(data.requestBody ?? "")
|
||||
);
|
||||
const responseBodyText = truncateBody(data.responseText ?? "");
|
||||
|
||||
// Uniform, capability-agnostic transcript for search/display -
|
||||
// computed from the untruncated originals so normalization sees
|
||||
// the full content; the normalized result gets its own
|
||||
// (typically much smaller) truncation pass below.
|
||||
const normalizedRequestMessages = normalizeAiRequest(
|
||||
data.capability,
|
||||
data.requestBody
|
||||
);
|
||||
const normalizedResponseMessages = normalizeAiResponse(
|
||||
data.capability,
|
||||
data.responseText ?? "",
|
||||
data.isStream
|
||||
);
|
||||
const normalizedRequestText = normalizedRequestMessages
|
||||
? truncateBody(JSON.stringify(normalizedRequestMessages))
|
||||
: null;
|
||||
const normalizedResponseText = normalizedResponseMessages
|
||||
? truncateBody(JSON.stringify(normalizedResponseMessages))
|
||||
: null;
|
||||
|
||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
||||
logger.warn(
|
||||
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
providerId: data.provider.providerId,
|
||||
capability: data.capability,
|
||||
resourceId: data.resourceId ?? undefined,
|
||||
siteResourceId: data.siteResourceId ?? undefined,
|
||||
userId: sanitizeString(data.requestUserId ?? undefined),
|
||||
virtualApiKeyId: sanitizeString(
|
||||
data.virtualApiKeyId ?? undefined
|
||||
),
|
||||
requestedModel: sanitizeString(data.requestedModel),
|
||||
isStream: data.isStream,
|
||||
requestBody: sanitizeString(requestBodyText.value),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
responseBodyText.truncated ||
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
statusCode: data.statusCode,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
if (sessionLogBuffer.length >= BATCH_SIZE) {
|
||||
flushSessionLogs().catch((err) =>
|
||||
logger.error("Error flushing AI session logs:", err)
|
||||
);
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to log AI session", { error });
|
||||
}
|
||||
})();
|
||||
}
|
||||
}): void {}
|
||||
|
||||
@@ -86,7 +86,7 @@ import {
|
||||
type AiUsage
|
||||
} from "@server/lib/aiUsageExtraction";
|
||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||
import { logAiSession } from "@server/routers/aiGateway/logAiSession";
|
||||
import { logAiSession } from "#dynamic/routers/aiGateway/logAiSession";
|
||||
|
||||
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
|
||||
const EXIT_NODE_RANGES_TTL_SEC = 6000;
|
||||
@@ -137,7 +137,7 @@ async function findClientByIp(ip: string): Promise<CachedClient> {
|
||||
return result;
|
||||
}
|
||||
|
||||
type ProviderAttachment = {
|
||||
export type ProviderAttachment = {
|
||||
provider: AiProvider;
|
||||
accessMode: AccessMode;
|
||||
};
|
||||
@@ -149,12 +149,12 @@ type ResourceModelPattern = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ProviderPatternLists = {
|
||||
export type ProviderPatternLists = {
|
||||
allows: string[];
|
||||
blocks: string[];
|
||||
};
|
||||
|
||||
type ResolvedTarget = {
|
||||
export type ResolvedTarget = {
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
orgId: string | null;
|
||||
@@ -362,7 +362,7 @@ function getRequestHeader(req: Request, name: string): string | undefined {
|
||||
// request came through, per the trust middleware's resource-type header -
|
||||
// falls back to checking both (public preferred on overlap) only when that
|
||||
// header is absent, e.g. a request that reached the gateway outside Traefik.
|
||||
async function resolveTarget(
|
||||
export async function resolveTarget(
|
||||
host: string,
|
||||
resourceType: AiGatewayResourceType | null
|
||||
): Promise<ResolvedTarget | null> {
|
||||
@@ -728,7 +728,9 @@ export function recordAiGatewayCompletion(args: {
|
||||
let cost: ReturnType<typeof calculateAiCost> = null;
|
||||
|
||||
if (upstreamSucceeded) {
|
||||
usage = extractUsage(capability, responseText, isStream, headers) ?? emptyUsage();
|
||||
usage =
|
||||
extractUsage(capability, responseText, isStream, headers) ??
|
||||
emptyUsage();
|
||||
if (isUsageEmpty(usage)) {
|
||||
usage = estimateUsage(
|
||||
JSON.stringify(requestBody ?? ""),
|
||||
@@ -810,6 +812,17 @@ export function recordAiGatewayCompletion(args: {
|
||||
});
|
||||
}
|
||||
|
||||
// p-host is only used sometimes when overriding the host header for some
|
||||
// middleware proxy. Shared with the model-discovery endpoint so both resolve
|
||||
// the inference resource off the same hostname.
|
||||
export function resolveGatewayHost(req: Request): string {
|
||||
return (
|
||||
(req.headers["p-host"] as string | undefined) ||
|
||||
req.headers.host ||
|
||||
""
|
||||
).split(":")[0];
|
||||
}
|
||||
|
||||
export async function handleAiGatewayProxy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -818,11 +831,7 @@ export async function handleAiGatewayProxy(
|
||||
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];
|
||||
const host = resolveGatewayHost(req);
|
||||
if (!host) {
|
||||
return res
|
||||
.status(HttpCode.BAD_REQUEST)
|
||||
|
||||
@@ -99,6 +99,7 @@ async function fetchProviderTargets(
|
||||
method: targets.method,
|
||||
exitNodeSubnet: sites.exitNodeSubnet,
|
||||
reachableAt: exitNodes.reachableAt,
|
||||
exitNodeType: exitNodes.type,
|
||||
hcHealth: targetHealthCheck.hcHealth
|
||||
})
|
||||
.from(targets)
|
||||
@@ -119,6 +120,12 @@ async function fetchProviderTargets(
|
||||
if (!row.exitNodeSubnet || !row.reachableAt) {
|
||||
continue;
|
||||
}
|
||||
// Sites connected to a remote exit node aren't reachable via a
|
||||
// gerbil sidecar's /router/* proxy - only "gerbil" type exit nodes
|
||||
// run that endpoint.
|
||||
if (row.exitNodeType !== "gerbil") {
|
||||
continue;
|
||||
}
|
||||
// A target with an active health check that's currently failing is
|
||||
// taken out of rotation. No health check (null) or "unknown" (check
|
||||
// hasn't run yet / hcEnabled is off) still routes normally, matching
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Request, Response } from "express";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { aiModels, db } from "@server/db";
|
||||
import {
|
||||
providerHasCapability,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
buildAiCapabilityErrorBody,
|
||||
type AiCapabilityErrorKind
|
||||
} from "@server/lib/aiGatewayAuthError";
|
||||
import {
|
||||
getAiGatewayResourceType,
|
||||
isAiGatewayTrustHeaderValid
|
||||
} from "@server/lib/aiGatewayTrust";
|
||||
import { resolveEffectiveLists } from "@server/lib/aiInferenceResource";
|
||||
import { listCatalogEntriesForType } from "@server/lib/aiModelCatalog";
|
||||
import {
|
||||
listPermittedModels,
|
||||
paginateModels,
|
||||
MODEL_PAGE_DEFAULT_LIMIT,
|
||||
MODEL_PAGE_MAX_LIMIT,
|
||||
type CatalogModelMetadata,
|
||||
type ConfiguredModel,
|
||||
type ModelDiscoveryProvider
|
||||
} from "@server/lib/aiModelDiscovery";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
import {
|
||||
resolveGatewayHost,
|
||||
resolveTarget,
|
||||
type ProviderAttachment,
|
||||
type ProviderPatternLists
|
||||
} from "@server/routers/aiGateway/pipeline";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
const CAPABILITY: AiCapability = "v1_models";
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(MODEL_PAGE_MAX_LIMIT).optional(),
|
||||
after_id: z.string().min(1).optional(),
|
||||
before_id: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
type ProviderModelLists = {
|
||||
allowsByProvider: Map<number, string[]>;
|
||||
blocksByProvider: Map<number, string[]>;
|
||||
configuredByProvider: Map<number, Map<string, ConfiguredModel>>;
|
||||
};
|
||||
|
||||
function errorResponse(
|
||||
res: Response,
|
||||
status: number,
|
||||
kind: AiCapabilityErrorKind,
|
||||
message: string
|
||||
) {
|
||||
return res
|
||||
.status(status)
|
||||
.json(buildAiCapabilityErrorBody(CAPABILITY, kind, message, status));
|
||||
}
|
||||
|
||||
// Provider-level allow/block lists, plus the display name and creation time of
|
||||
// every catalog row, so explicitly configured models are reported with the name
|
||||
// the administrator gave them rather than a bare model id.
|
||||
async function loadProviderModelLists(
|
||||
providerIds: number[]
|
||||
): Promise<ProviderModelLists> {
|
||||
const lists: ProviderModelLists = {
|
||||
allowsByProvider: new Map(),
|
||||
blocksByProvider: new Map(),
|
||||
configuredByProvider: new Map()
|
||||
};
|
||||
|
||||
if (providerIds.length === 0) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey,
|
||||
name: aiModels.name,
|
||||
listType: aiModels.listType,
|
||||
enabled: aiModels.enabled,
|
||||
createdAt: aiModels.createdAt
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(inArray(aiModels.providerId, providerIds));
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) {
|
||||
continue;
|
||||
}
|
||||
const targetMap =
|
||||
row.listType === "allow"
|
||||
? lists.allowsByProvider
|
||||
: lists.blocksByProvider;
|
||||
const existing = targetMap.get(row.providerId) ?? [];
|
||||
existing.push(row.modelKey);
|
||||
targetMap.set(row.providerId, existing);
|
||||
|
||||
let configured = lists.configuredByProvider.get(row.providerId);
|
||||
if (!configured) {
|
||||
configured = new Map();
|
||||
lists.configuredByProvider.set(row.providerId, configured);
|
||||
}
|
||||
configured.set(row.modelKey, {
|
||||
name: row.name,
|
||||
createdAt: row.createdAt
|
||||
});
|
||||
}
|
||||
|
||||
return lists;
|
||||
}
|
||||
|
||||
function catalogMetadataForType(
|
||||
type: AiProviderType
|
||||
): Map<string, CatalogModelMetadata> {
|
||||
const metadata = new Map<string, CatalogModelMetadata>();
|
||||
for (const entry of listCatalogEntriesForType(type)) {
|
||||
metadata.set(entry.model, {
|
||||
maxInputTokens: entry.limits.input,
|
||||
maxOutputTokens: entry.limits.output,
|
||||
capabilities: entry.capabilities
|
||||
});
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function buildDiscoveryProviders(
|
||||
attachments: ProviderAttachment[],
|
||||
resourceListsByProvider: Map<number, ProviderPatternLists>,
|
||||
lists: ProviderModelLists
|
||||
): ModelDiscoveryProvider[] {
|
||||
return attachments.map((attachment) => {
|
||||
const providerId = attachment.provider.providerId;
|
||||
const resourceLists = resourceListsByProvider.get(providerId);
|
||||
const { allows, blocks } = resolveEffectiveLists({
|
||||
accessMode: attachment.accessMode,
|
||||
providerAllows: lists.allowsByProvider.get(providerId) ?? [],
|
||||
providerBlocks: lists.blocksByProvider.get(providerId) ?? [],
|
||||
resourceAllows: resourceLists?.allows ?? [],
|
||||
resourceBlocks: resourceLists?.blocks ?? []
|
||||
});
|
||||
|
||||
return {
|
||||
providerId,
|
||||
allows,
|
||||
blocks,
|
||||
catalog: catalogMetadataForType(
|
||||
attachment.provider.type as AiProviderType
|
||||
),
|
||||
configured: lists.configuredByProvider.get(providerId) ?? new Map()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves Anthropic's model-discovery endpoints (`GET /v1/models` and
|
||||
* `GET /v1/models/{id}`) for an inference resource. The gateway answers these
|
||||
* itself rather than proxying: upstream providers either don't expose a model
|
||||
* list at all or would expose models the resource's allow/block lists forbid,
|
||||
* so the response is built from the same effective lists that gate inference.
|
||||
*/
|
||||
export async function handleV1Models(
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<any> {
|
||||
try {
|
||||
const host = resolveGatewayHost(req);
|
||||
if (!host) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Missing Host header"
|
||||
);
|
||||
}
|
||||
|
||||
const resourceType = getAiGatewayResourceType(
|
||||
req.headers as Record<string, string>
|
||||
);
|
||||
const target = await resolveTarget(host, resourceType);
|
||||
if (!target) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.NOT_FOUND,
|
||||
"not_found",
|
||||
"No inference resource found for this host"
|
||||
);
|
||||
}
|
||||
|
||||
// Same gate as the inference pipeline: public inference must pass
|
||||
// Badger verify-session first, which is what stamps the trust header.
|
||||
if (
|
||||
target.resourceId != null &&
|
||||
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
|
||||
) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"authentication",
|
||||
"Request must be authenticated via the inference resource"
|
||||
);
|
||||
}
|
||||
|
||||
if (target.attachments.length === 0) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.FORBIDDEN,
|
||||
"permission",
|
||||
"No AI providers configured for this resource"
|
||||
);
|
||||
}
|
||||
|
||||
const capableAttachments = target.attachments.filter((a) =>
|
||||
providerHasCapability(a.provider.capabilities, CAPABILITY)
|
||||
);
|
||||
if (capableAttachments.length === 0) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.FORBIDDEN,
|
||||
"permission",
|
||||
`No AI provider on this resource supports ${CAPABILITY}`
|
||||
);
|
||||
}
|
||||
|
||||
const lists = await loadProviderModelLists(
|
||||
capableAttachments.map((a) => a.provider.providerId)
|
||||
);
|
||||
const models = listPermittedModels(
|
||||
buildDiscoveryProviders(
|
||||
capableAttachments,
|
||||
target.resourceListsByProvider,
|
||||
lists
|
||||
)
|
||||
);
|
||||
|
||||
// `GET /v1/models/{id}` - a single model, 404 when this resource
|
||||
// doesn't permit it.
|
||||
const requestedModel = req.params?.model;
|
||||
if (typeof requestedModel === "string" && requestedModel.length > 0) {
|
||||
const model = models.find((m) => m.id === requestedModel);
|
||||
if (!model) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.NOT_FOUND,
|
||||
"not_found",
|
||||
`Model "${requestedModel}" is not available on this resource`
|
||||
);
|
||||
}
|
||||
return res.status(HttpCode.OK).json(model);
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.BAD_REQUEST,
|
||||
"invalid_request",
|
||||
parsedQuery.error.issues[0]?.message ??
|
||||
"Invalid pagination parameters"
|
||||
);
|
||||
}
|
||||
|
||||
const page = paginateModels(
|
||||
models,
|
||||
parsedQuery.data.limit ?? MODEL_PAGE_DEFAULT_LIMIT,
|
||||
{
|
||||
afterId: parsedQuery.data.after_id,
|
||||
beforeId: parsedQuery.data.before_id
|
||||
}
|
||||
);
|
||||
if ("error" in page) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.BAD_REQUEST,
|
||||
"invalid_request",
|
||||
page.error
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("AI gateway model discovery", {
|
||||
host,
|
||||
resourceId: target.resourceId,
|
||||
siteResourceId: target.siteResourceId,
|
||||
providers: capableAttachments.length,
|
||||
total: models.length,
|
||||
returned: page.data.length
|
||||
});
|
||||
|
||||
return res.status(HttpCode.OK).json({
|
||||
data: page.data,
|
||||
has_more: page.has_more,
|
||||
first_id: page.data[0]?.id ?? null,
|
||||
last_id: page.data[page.data.length - 1]?.id ?? null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
"Failed to list models"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { response } from "@server/lib/response";
|
||||
import { db } from "@server/db";
|
||||
import { passwordResetTokens, users } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||
import { createDate } from "oslo";
|
||||
import logger from "@server/logger";
|
||||
@@ -49,12 +49,7 @@ export async function requestPasswordReset(
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
);
|
||||
.where(eq(users.email, email));
|
||||
|
||||
if (!existingUser || !existingUser.length) {
|
||||
await randomDelay(2000);
|
||||
|
||||
@@ -23,6 +23,12 @@ export async function createCertificate(
|
||||
throw new Error(`Domain with ID ${domainId} not found`);
|
||||
}
|
||||
|
||||
// Note: certificates.domain has a global UNIQUE constraint (it is not
|
||||
// scoped per-domainId), so existence must be checked by domain value
|
||||
// alone. Filtering on domainId here as well can cause this check to
|
||||
// miss an existing cert (e.g. if it was stored under a different but
|
||||
// still-valid domainId), leading to an INSERT that then fails on the
|
||||
// unique constraint.
|
||||
let existing: Certificate[] = [];
|
||||
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
@@ -32,16 +38,13 @@ export async function createCertificate(
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
or(
|
||||
eq(certificates.domain, domain),
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
eq(certificates.domain, domainLevelDown),
|
||||
eq(certificates.domain, wildcardPrefixed)
|
||||
)
|
||||
or(
|
||||
eq(certificates.domain, domain),
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
eq(certificates.domain, domainLevelDown),
|
||||
eq(certificates.domain, wildcardPrefixed)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -51,12 +54,7 @@ export async function createCertificate(
|
||||
existing = await trx
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
eq(certificates.domain, domain) // exact match for non-NS domains
|
||||
)
|
||||
);
|
||||
.where(eq(certificates.domain, domain)); // exact match for non-NS domains
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
@@ -87,16 +85,22 @@ export async function createCertificate(
|
||||
}
|
||||
}
|
||||
|
||||
// No cert found, create a new one in pending state
|
||||
await trx.insert(certificates).values({
|
||||
domain: domainToWrite,
|
||||
domainId,
|
||||
wildcard:
|
||||
domainRecord.type == "ns" ||
|
||||
(domainRecord.type == "wildcard" &&
|
||||
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
|
||||
status: "pending",
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
createdAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
// No cert found, create a new one in pending state. onConflictDoNothing
|
||||
// guards against the domain having been inserted concurrently (or under
|
||||
// a different domainId) between the existence check above and this
|
||||
// insert, since certificates.domain is globally unique.
|
||||
await trx
|
||||
.insert(certificates)
|
||||
.values({
|
||||
domain: domainToWrite,
|
||||
domainId,
|
||||
wildcard:
|
||||
domainRecord.type == "ns" ||
|
||||
(domainRecord.type == "wildcard" &&
|
||||
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
|
||||
status: "pending",
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
createdAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
@@ -1490,21 +1490,6 @@ authenticated.get(
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/usage/filters",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -15,13 +15,7 @@ export async function createExitNode(
|
||||
if (!exitNodeQuery) {
|
||||
const { value: address, release } = await getNextAvailableSubnet();
|
||||
try {
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
@@ -32,7 +26,7 @@ export async function createExitNode(
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
||||
address,
|
||||
online: true,
|
||||
listenPort,
|
||||
|
||||
@@ -1532,21 +1532,6 @@ authenticated.get(
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/export",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/ai/usage/filters",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -1,9 +1,248 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { inflate } from "zlib";
|
||||
import { promisify } from "util";
|
||||
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
|
||||
export async function flushRequestLogToDb(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const zlibInflate = promisify(inflate);
|
||||
|
||||
interface HTTPRequestLogData {
|
||||
requestId: string;
|
||||
resourceId: number; // siteResourceId
|
||||
timestamp: string; // ISO 8601
|
||||
method: string;
|
||||
scheme: string; // "http" or "https"
|
||||
host: string;
|
||||
path: string;
|
||||
rawQuery?: string;
|
||||
userAgent?: string;
|
||||
sourceAddr: string; // ip:port
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||
*/
|
||||
async function decompressRequestLog(
|
||||
compressed: string
|
||||
): Promise<HTTPRequestLogData[]> {
|
||||
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||
const decompressed = await zlibInflate(compressedBuffer);
|
||||
const jsonString = decompressed.toString("utf-8");
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Decompressed request log data is not an array");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
};
|
||||
const { message, client } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Request log received but no newt client in context");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Request log received but newt has no siteId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.data?.compressed) {
|
||||
logger.warn("Request log message missing compressed data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
|
||||
if (!site) {
|
||||
logger.warn(
|
||||
`Request log received but site ${newt.siteId} not found in database`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysRequest === 0) {
|
||||
logger.debug(
|
||||
`Request log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: HTTPRequestLogData[];
|
||||
try {
|
||||
entries = await decompressRequestLog(message.data.compressed);
|
||||
} catch (error) {
|
||||
logger.error("Failed to decompress request log data:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
||||
|
||||
// Build a map from sourceIp → external endpoint string by joining clients
|
||||
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
|
||||
// of the client device and is used for GeoIP lookup.
|
||||
const ipToEndpoint = new Map<string, string>();
|
||||
// Build a map from sourceIp → the user associated with the client (if any)
|
||||
const ipToUser = new Map<string, { username: string; userId: string }>();
|
||||
|
||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||
: null;
|
||||
|
||||
if (cidrSuffix) {
|
||||
const uniqueSourceAddrs = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.sourceAddr) {
|
||||
uniqueSourceAddrs.add(entry.sourceAddr);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueSourceAddrs.size > 0) {
|
||||
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
|
||||
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||
return `${ip}${cidrSuffix}`;
|
||||
});
|
||||
|
||||
const matchedClients = await db
|
||||
.select({
|
||||
subnet: clients.subnet,
|
||||
endpoint: clientSitesAssociationsCache.endpoint,
|
||||
username: users.username,
|
||||
userId: users.userId
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSitesAssociationsCache,
|
||||
and(
|
||||
eq(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clients.clientId
|
||||
),
|
||||
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||
)
|
||||
)
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(clients.orgId, orgId),
|
||||
inArray(clients.subnet, subnetQueries)
|
||||
)
|
||||
);
|
||||
|
||||
for (const c of matchedClients) {
|
||||
const ip = c.subnet.split("/")[0];
|
||||
if (c.endpoint) {
|
||||
ipToEndpoint.set(ip, c.endpoint);
|
||||
}
|
||||
if (c.userId && c.username) {
|
||||
ipToUser.set(ip, { userId: c.userId, username: c.username });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.requestId ||
|
||||
!entry.resourceId ||
|
||||
!entry.method ||
|
||||
!entry.scheme ||
|
||||
!entry.host ||
|
||||
!entry.path ||
|
||||
!entry.sourceAddr
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRequestURL =
|
||||
entry.scheme +
|
||||
"://" +
|
||||
entry.host +
|
||||
entry.path +
|
||||
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
||||
|
||||
// Resolve the client's external endpoint for GeoIP lookup.
|
||||
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
|
||||
const sourceIp = entry.sourceAddr.includes(":")
|
||||
? entry.sourceAddr.split(":")[0]
|
||||
: entry.sourceAddr;
|
||||
const endpoint = ipToEndpoint.get(sourceIp);
|
||||
let location: string | undefined;
|
||||
if (endpoint) {
|
||||
const endpointIp = endpoint.includes(":")
|
||||
? endpoint.split(":")[0]
|
||||
: endpoint;
|
||||
location = await getCountryCodeForIp(endpointIp);
|
||||
}
|
||||
const user = ipToUser.get(sourceIp);
|
||||
|
||||
await logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 108,
|
||||
siteResourceId: entry.resourceId,
|
||||
orgId,
|
||||
location,
|
||||
user
|
||||
},
|
||||
{
|
||||
path: entry.path,
|
||||
originalRequestURL,
|
||||
scheme: entry.scheme,
|
||||
host: entry.host,
|
||||
method: entry.method,
|
||||
tls: entry.tls,
|
||||
requestIp: entry.sourceAddr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,7 +42,8 @@ export const handleOlmExitNodesRequestMessage: MessageHandler = async (
|
||||
client.orgId,
|
||||
true,
|
||||
noCloud || false,
|
||||
olm.clientId
|
||||
olm.clientId,
|
||||
true // don't select remote exit nodes for clients
|
||||
); // filter for only the online ones
|
||||
|
||||
let lastExitNodeId = null;
|
||||
|
||||
@@ -147,6 +147,42 @@ export async function updateOrg(
|
||||
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
|
||||
}
|
||||
|
||||
// Check access logs feature
|
||||
const hasAccessLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.AccessLogs]
|
||||
);
|
||||
if (!hasAccessLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysAccess = undefined;
|
||||
}
|
||||
|
||||
// Check action logs feature
|
||||
const hasActionLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.ActionLogs]
|
||||
);
|
||||
if (!hasActionLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysAction = undefined;
|
||||
}
|
||||
|
||||
// Check connection logs feature
|
||||
const hasConnectionLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.ConnectionLogs]
|
||||
);
|
||||
if (!hasConnectionLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysConnection = undefined;
|
||||
}
|
||||
|
||||
// Check AI session logs feature
|
||||
const hasAISessionLogsFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.AISessionLogs]
|
||||
);
|
||||
if (!hasAISessionLogsFeature) {
|
||||
parsedBody.data.settingsLogRetentionDaysAISessions = undefined;
|
||||
}
|
||||
|
||||
if (build == "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { idp, userResources, users } from "@server/db"; // Assuming these are the correct tables
|
||||
import { idp, resources, userPolicies, userResources, users } from "@server/db"; // Assuming these are the correct tables
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -14,7 +14,23 @@ const listResourceUsersSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
async function queryUsers(resourceId: number) {
|
||||
async function queryUsers(resourceId: number, policyId: number | null) {
|
||||
if (policyId !== null) {
|
||||
return await db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
username: users.username,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
email: users.email
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(users, eq(userPolicies.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select({
|
||||
userId: userResources.userId,
|
||||
@@ -104,7 +120,26 @@ export async function listResourceUsers(
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const resourceUsersList = await queryUsers(resourceId);
|
||||
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 isInlinePolicy =
|
||||
resource.resourcePolicyId === null &&
|
||||
resource.defaultResourcePolicyId !== null;
|
||||
|
||||
const resourceUsersList = await queryUsers(
|
||||
resourceId,
|
||||
isInlinePolicy ? resource.defaultResourcePolicyId! : null
|
||||
);
|
||||
|
||||
return response<ListResourceUsersResponse>(res, {
|
||||
data: {
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/clients/add",
|
||||
path: "/private-resource/{resourceId}/clients/add",
|
||||
description:
|
||||
"Add a single client to a site resource. Clients with a userId cannot be added.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/roles/add",
|
||||
path: "/private-resource/{resourceId}/roles/add",
|
||||
description: "Add a single role to a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
|
||||
request: {
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/users/add",
|
||||
path: "/private-resource/{resourceId}/users/add",
|
||||
description: "Add a single user to a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
|
||||
request: {
|
||||
|
||||
@@ -63,7 +63,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/private-resource/{siteResourceId}/clients",
|
||||
path: "/private-resource/{resourceId}/clients",
|
||||
description: "List all clients for a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
|
||||
request: {
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/private-resource/{siteResourceId}/roles",
|
||||
path: "/private-resource/{resourceId}/roles",
|
||||
description: "List all roles for a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
|
||||
request: {
|
||||
|
||||
@@ -67,7 +67,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/private-resource/{siteResourceId}/users",
|
||||
path: "/private-resource/{resourceId}/users",
|
||||
description: "List all users for a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
|
||||
request: {
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/clients/remove",
|
||||
path: "/private-resource/{resourceId}/clients/remove",
|
||||
description:
|
||||
"Remove a single client from a site resource. Clients with a userId cannot be removed.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/roles/remove",
|
||||
path: "/private-resource/{resourceId}/roles/remove",
|
||||
description: "Remove a single role from a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
|
||||
request: {
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/users/remove",
|
||||
path: "/private-resource/{resourceId}/users/remove",
|
||||
description: "Remove a single user from a site resource.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
|
||||
request: {
|
||||
|
||||
@@ -64,7 +64,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/clients",
|
||||
path: "/private-resource/{resourceId}/clients",
|
||||
description:
|
||||
"Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
|
||||
|
||||
@@ -65,7 +65,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/roles",
|
||||
path: "/private-resource/{resourceId}/roles",
|
||||
description:
|
||||
"Set roles for a site resource. This will replace all existing roles.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
|
||||
|
||||
@@ -66,7 +66,7 @@ registry.registerPath({
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/private-resource/{siteResourceId}/users",
|
||||
path: "/private-resource/{resourceId}/users",
|
||||
description:
|
||||
"Set users for a site resource. This will replace all existing users.",
|
||||
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
handleNewtExitNodesRequestMessage,
|
||||
handleApplyBlueprintMessage,
|
||||
handleNewtPingMessage,
|
||||
handleNewtDisconnectingMessage
|
||||
handleNewtDisconnectingMessage,
|
||||
handleRequestLogMessage
|
||||
} from "../newt";
|
||||
import {
|
||||
handleOlmRegisterMessage,
|
||||
@@ -46,5 +47,6 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"newt/ping/request": handleNewtExitNodesRequestMessage,
|
||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||
"newt/healthcheck/status": handleHealthcheckStatusMessage,
|
||||
"newt/request-log": handleRequestLogMessage,
|
||||
"ws/round-trip/complete": handleRoundTripMessage
|
||||
};
|
||||
|
||||
@@ -388,7 +388,7 @@ const setupConnection = async (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Message handling error:", error);
|
||||
logger.warn("Message handling error:", error);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
|
||||
@@ -8,6 +8,8 @@ import ExitNodesTable, {
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Metadata } from "next";
|
||||
import { build } from "@server/build";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Remote Exit Nodes"
|
||||
@@ -22,6 +24,10 @@ export const dynamic = "force-dynamic";
|
||||
export default async function RemoteExitNodesPage(
|
||||
props: RemoteExitNodesPageProps
|
||||
) {
|
||||
if (build != "saas") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = [];
|
||||
try {
|
||||
|
||||
@@ -345,6 +345,7 @@ export default function AiProviderNetworkPage() {
|
||||
ref={targetsFormRef}
|
||||
orgId={orgId}
|
||||
isHttp
|
||||
isAiProvider
|
||||
providerId={provider.providerId}
|
||||
initialTargets={
|
||||
isTargetModeSaved ? remoteTargets : []
|
||||
|
||||
@@ -682,6 +682,7 @@ export default function CreateAiProviderPage() {
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={orgId}
|
||||
isHttp
|
||||
isAiProvider
|
||||
onChange={(nextTargets) => {
|
||||
targetsRef.current = nextTargets;
|
||||
}}
|
||||
|
||||
@@ -298,101 +298,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAISessions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logRetentionAISessionsLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
parseInt(value, 10)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(option) => {
|
||||
if (
|
||||
build != "saas"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let maxDays: number;
|
||||
|
||||
if (
|
||||
!subscriptionTier
|
||||
) {
|
||||
// No tier
|
||||
maxDays = 3;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"enterprise"
|
||||
) {
|
||||
// Enterprise - no limit
|
||||
return true;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier3"
|
||||
) {
|
||||
maxDays = 90;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier2"
|
||||
) {
|
||||
maxDays = 30;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier1"
|
||||
) {
|
||||
maxDays = 7;
|
||||
} else {
|
||||
// Default to most restrictive
|
||||
maxDays = 3;
|
||||
}
|
||||
|
||||
// Filter out options that exceed the max
|
||||
// Special values: -1 (forever) and 9001 (end of year) should be filtered
|
||||
if (
|
||||
option.value <
|
||||
0 ||
|
||||
option.value >
|
||||
maxDays
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
).map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
@@ -774,6 +679,131 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAISessions"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.aiSessionLogs
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionAISessionsLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.toString()}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
field.onChange(
|
||||
parseInt(
|
||||
value,
|
||||
10
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectLogRetention"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.filter(
|
||||
(
|
||||
option
|
||||
) => {
|
||||
if (
|
||||
build !=
|
||||
"saas"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let maxDays: number;
|
||||
|
||||
if (
|
||||
!subscriptionTier
|
||||
) {
|
||||
// No tier
|
||||
maxDays = 3;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"enterprise"
|
||||
) {
|
||||
// Enterprise - no limit
|
||||
return true;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier3"
|
||||
) {
|
||||
maxDays = 90;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier2"
|
||||
) {
|
||||
maxDays = 30;
|
||||
} else if (
|
||||
subscriptionTier ==
|
||||
"tier1"
|
||||
) {
|
||||
maxDays = 7;
|
||||
} else {
|
||||
// Default to most restrictive
|
||||
maxDays = 3;
|
||||
}
|
||||
|
||||
// Filter out options that exceed the max
|
||||
// Special values: -1 (forever) and 9001 (end of year) should be filtered
|
||||
if (
|
||||
option.value <
|
||||
0 ||
|
||||
option.value >
|
||||
maxDays
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
).map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { LogDataTable } from "@app/components/LogDataTable";
|
||||
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import LogRetentionWarning from "@app/components/LogRetentionWarning";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -15,6 +17,8 @@ import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
@@ -29,6 +33,7 @@ const capabilityLabels: Record<string, string> = {
|
||||
openai_chat: "OpenAI Chat Completions",
|
||||
openai_responses: "OpenAI Responses",
|
||||
anthropic_messages: "Anthropic Messages",
|
||||
v1_models: "Models List",
|
||||
gemini_generate_content: "Gemini",
|
||||
google_generate_content: "Vertex AI (Generate Content)",
|
||||
google_raw_predict: "Vertex AI (Raw Predict)",
|
||||
@@ -44,6 +49,7 @@ export default function AiSessionLogsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
@@ -133,7 +139,8 @@ export default function AiSessionLogsPage() {
|
||||
...logQueries.aiSessions({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
})
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||
@@ -645,6 +652,8 @@ export default function AiSessionLogsPage() {
|
||||
description={t("aiSessionLogsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
|
||||
|
||||
{org.org.settingsLogRetentionDaysAISessions === 0 && (
|
||||
<LogRetentionWarning
|
||||
orgId={orgId as string}
|
||||
@@ -679,6 +688,9 @@ export default function AiSessionLogsPage() {
|
||||
pageSize={pageSize}
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -88,8 +88,8 @@ export default async function ClientResourcesPage(
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||
|
||||
@@ -113,6 +113,8 @@ type ProxyResourceTargetsFormProps = {
|
||||
hideSaveButton?: boolean;
|
||||
/** Hide the advanced mode toggle and always use non-advanced mode (e.g. AI providers) */
|
||||
disableAdvancedMode?: boolean;
|
||||
/** Targets picker is for an AI provider (changes which routing warnings are shown) */
|
||||
isAiProvider?: boolean;
|
||||
};
|
||||
|
||||
export const ProxyResourceTargetsForm = forwardRef<
|
||||
@@ -131,7 +133,8 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
emptyMessage,
|
||||
embedded = false,
|
||||
hideSaveButton = false,
|
||||
disableAdvancedMode = false
|
||||
disableAdvancedMode = false,
|
||||
isAiProvider = false
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -259,6 +262,14 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
})
|
||||
);
|
||||
|
||||
const { data: remoteExitNodes = [] } = useQuery({
|
||||
...orgQueries.remoteExitNodes({ orgId }),
|
||||
enabled: build === "saas" && isAiProvider
|
||||
});
|
||||
const hasRemoteExitNodes = remoteExitNodes.some(
|
||||
(node) => node.exitNodeId !== null
|
||||
);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
@@ -972,6 +983,7 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" &&
|
||||
!isAiProvider &&
|
||||
targets.length > 1 &&
|
||||
new Set(targets.map((t) => t.siteId)).size > 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
@@ -988,6 +1000,11 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{build === "saas" && isAiProvider && hasRemoteExitNodes && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("aiProviderRemoteNodeTargetsWarning")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function Page(props: {
|
||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type
|
||||
variant: idp.variant ?? idp.type
|
||||
})) as LoginFormIDP[];
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
|
||||
openai_chat: "aiCapabilityOpenaiChat",
|
||||
openai_responses: "aiCapabilityOpenaiResponses",
|
||||
anthropic_messages: "aiCapabilityAnthropicMessages",
|
||||
v1_models: "aiCapabilityV1Models",
|
||||
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
||||
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
||||
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
||||
|
||||
@@ -79,7 +79,7 @@ function MessageBubble({ message }: { message: NormalizedAiMessage }) {
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
className={`min-w-0 max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isTool
|
||||
@@ -108,7 +108,7 @@ function RawFallbackBlock({
|
||||
}) {
|
||||
const pretty = prettyRaw(raw);
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="min-w-0 rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
{pretty && unparsedLabel && (
|
||||
@@ -117,7 +117,7 @@ function RawFallbackBlock({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all text-xs text-muted-foreground">
|
||||
{pretty ?? noDataLabel}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export function AiSessionChatView({
|
||||
</Button>
|
||||
</div>
|
||||
{rawMode ? (
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<div className="flex min-w-0 max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionRequest")}
|
||||
raw={normalizedRequest}
|
||||
@@ -185,7 +185,7 @@ export function AiSessionChatView({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<div className="flex min-w-0 max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
{hasRequestMessages ? (
|
||||
requestMessages!.map((message, i) => (
|
||||
<MessageBubble key={`req-${i}`} message={message} />
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface Destination {
|
||||
sendActionLogs: boolean;
|
||||
sendConnectionLogs: boolean;
|
||||
sendRequestLogs: boolean;
|
||||
sendAISessionLogs: boolean;
|
||||
lastError: string | null;
|
||||
lastErrorAt: number | null;
|
||||
createdAt: number;
|
||||
@@ -180,6 +181,7 @@ export function HttpDestinationCredenza({
|
||||
const [sendActionLogs, setSendActionLogs] = useState(false);
|
||||
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
|
||||
const [sendRequestLogs, setSendRequestLogs] = useState(false);
|
||||
const [sendAISessionLogs, setSendAISessionLogs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -190,6 +192,7 @@ export function HttpDestinationCredenza({
|
||||
setSendActionLogs(editing?.sendActionLogs ?? false);
|
||||
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
|
||||
setSendRequestLogs(editing?.sendRequestLogs ?? false);
|
||||
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
|
||||
}
|
||||
}, [open, editing]);
|
||||
|
||||
@@ -226,7 +229,8 @@ export function HttpDestinationCredenza({
|
||||
sendAccessLogs,
|
||||
sendActionLogs,
|
||||
sendConnectionLogs,
|
||||
sendRequestLogs
|
||||
sendRequestLogs,
|
||||
sendAISessionLogs
|
||||
};
|
||||
if (editing) {
|
||||
await api.post(
|
||||
@@ -778,6 +782,30 @@ export function HttpDestinationCredenza({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||
<Checkbox
|
||||
id="log-ai-session"
|
||||
checked={sendAISessionLogs}
|
||||
onCheckedChange={(v) =>
|
||||
setSendAISessionLogs(v === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="log-ai-session"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{t("httpDestAISessionLogsTitle")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t(
|
||||
"httpDestAISessionLogsDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
|
||||
@@ -313,6 +313,15 @@ export function LogDataTable<TData, TValue>({
|
||||
}
|
||||
}, [currentPage, table, isServerPagination]);
|
||||
|
||||
// Collapse any expanded rows whenever the page changes, since row ids
|
||||
// are reused across pages and would otherwise show the wrong content
|
||||
// in the same expanded position.
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
useEffect(() => {
|
||||
setExpandedRows(new Set());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageIndex]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
@@ -515,9 +524,13 @@ export function LogDataTable<TData, TValue>({
|
||||
}
|
||||
className="p-4 bg-muted/50"
|
||||
>
|
||||
{renderExpandedRow(
|
||||
row.original
|
||||
)}
|
||||
{/* w-0 min-w-full keeps this cell's content from */}
|
||||
{/* blowing out the table's auto column widths */}
|
||||
<div className="w-0 min-w-full">
|
||||
{renderExpandedRow(
|
||||
row.original
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ export function S3DestinationCredenza({
|
||||
const [sendActionLogs, setSendActionLogs] = useState(false);
|
||||
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
|
||||
const [sendRequestLogs, setSendRequestLogs] = useState(false);
|
||||
const [sendAISessionLogs, setSendAISessionLogs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -98,6 +99,7 @@ export function S3DestinationCredenza({
|
||||
setSendActionLogs(editing?.sendActionLogs ?? false);
|
||||
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
|
||||
setSendRequestLogs(editing?.sendRequestLogs ?? false);
|
||||
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
|
||||
}
|
||||
}, [open, editing]);
|
||||
|
||||
@@ -121,7 +123,8 @@ export function S3DestinationCredenza({
|
||||
sendAccessLogs,
|
||||
sendActionLogs,
|
||||
sendConnectionLogs,
|
||||
sendRequestLogs
|
||||
sendRequestLogs,
|
||||
sendAISessionLogs
|
||||
};
|
||||
if (editing) {
|
||||
await api.post(
|
||||
@@ -510,6 +513,30 @@ export function S3DestinationCredenza({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||
<Checkbox
|
||||
id="s3-log-ai-session"
|
||||
checked={sendAISessionLogs}
|
||||
onCheckedChange={(v) =>
|
||||
setSendAISessionLogs(v === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="s3-log-ai-session"
|
||||
className="cursor-pointer font-medium"
|
||||
>
|
||||
{t("httpDestAISessionLogsTitle")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t(
|
||||
"httpDestAISessionLogsDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
|
||||
@@ -49,6 +49,10 @@ const CLIENT_LOGOS = {
|
||||
opencode: {
|
||||
light: "/third-party/opencode-dark.svg",
|
||||
dark: "/third-party/opencode-light.svg"
|
||||
},
|
||||
gemini: {
|
||||
light: "/third-party/gemini-dark.svg",
|
||||
dark: "/third-party/gemini-light.svg"
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -65,7 +69,8 @@ export function AiClientConfigSection({
|
||||
const descriptions: Record<string, string> = {
|
||||
claude: t("aiClientConfigDescriptionClaude"),
|
||||
codex: t("aiClientConfigDescriptionCodex"),
|
||||
opencode: t("aiClientConfigDescriptionOpencode")
|
||||
opencode: t("aiClientConfigDescriptionOpencode"),
|
||||
gemini: t("aiClientConfigDescriptionGemini")
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -129,6 +129,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
|
||||
payload?.[0]?.payload?.day
|
||||
)
|
||||
}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -172,6 +173,7 @@ export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
|
||||
payload?.[0]?.payload?.day
|
||||
)
|
||||
}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,8 @@ export function buildSeriesFromData(
|
||||
export const currencyFormatter = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
|
||||
export const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
|
||||
+16
-10
@@ -135,6 +135,7 @@ type ChartTooltipContentProps = React.ComponentProps<"div"> & {
|
||||
labelKey?: string;
|
||||
color?: string;
|
||||
labelClassName?: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
@@ -155,7 +156,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey
|
||||
labelKey,
|
||||
valueFormatter
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -302,19 +304,23 @@ const ChartTooltipContent = React.forwardRef<
|
||||
item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
{item.value !== undefined && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{!isNaN(
|
||||
item.value as number
|
||||
)
|
||||
? new Intl.NumberFormat(
|
||||
navigator.language,
|
||||
{
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(
|
||||
item.value as number
|
||||
)
|
||||
? valueFormatter
|
||||
? valueFormatter(
|
||||
item.value as number
|
||||
)
|
||||
: new Intl.NumberFormat(
|
||||
navigator.language,
|
||||
{
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(
|
||||
item.value as number
|
||||
)
|
||||
: item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { detectLocale } from "./detectLocale";
|
||||
|
||||
function runTests() {
|
||||
assertEquals(
|
||||
detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"),
|
||||
"zh-TW",
|
||||
"An exact regional match should take precedence over a language fallback"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("ZH-tw"),
|
||||
"zh-TW",
|
||||
"Locale matching should be case-insensitive"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "),
|
||||
"zh-TW",
|
||||
"Whitespace and quality parameters should not prevent an exact match"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh-CN,zh-TW;q=0.9"),
|
||||
"zh-CN",
|
||||
"Simplified Chinese should still match exactly"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh"),
|
||||
"zh-CN",
|
||||
"A generic Chinese preference should retain the existing fallback"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("en-GB,en;q=0.9"),
|
||||
"en-US",
|
||||
"An unsupported region should fall back to a supported locale for the language"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("ja-JP,zh-TW;q=0.9"),
|
||||
"zh-TW",
|
||||
"The next preference should be used when a language is unsupported"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"),
|
||||
"zh-TW",
|
||||
"Preferences should be evaluated by quality"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh-TW;q=0,fr-FR;q=0.8"),
|
||||
"fr-FR",
|
||||
"Locales with zero quality should be excluded"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("*,de-DE;q=0.8"),
|
||||
"de-DE",
|
||||
"A wildcard should not obscure a supported preference"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("ja-JP"),
|
||||
undefined,
|
||||
"An unsupported language should not match"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale(""),
|
||||
undefined,
|
||||
"An empty Accept-Language header should not match"
|
||||
);
|
||||
|
||||
console.log("All locale detection tests passed!");
|
||||
}
|
||||
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Locale detection test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user