Compare commits

..

30 Commits

Author SHA1 Message Date
dependabot[bot] a18332c689 Bump the docker-dependencies group across 1 directory with 2 updates
Bumps the docker-dependencies group with 2 updates in the / directory: docker/library/node and node.


Updates `docker/library/node` from 24.18.1-slim to 26.7.0-slim

Updates `node` from 24.18.1-alpine to 26.7.0-alpine

---
updated-dependencies:
- dependency-name: docker/library/node
  dependency-version: 26.7.0-slim
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
- dependency-name: node
  dependency-version: 26.7.0-alpine
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 01:34:21 +00:00
Milo Schwartz fd0a0818c1 Merge pull request #3617 from fosrl/dev
Dev
2026-08-19 16:41:48 -04:00
Milo Schwartz 929acc5b1c Merge pull request #3614 from fosrl/dev
Dev
2026-08-19 12:05:12 -04:00
Milo Schwartz 71348f45b2 Merge pull request #3611 from fosrl/dev
1.22.0
2026-08-19 11:26:08 -04:00
Milo Schwartz 21eb4d2876 Merge pull request #3598 from shleeable/patch-12
Fix: ensuring only internal users can request password resets.
2026-08-19 10:35:52 -04:00
Owen Schwartz 18270381c1 Merge pull request #3607 from fosrl/dependabot/npm_and_yarn/multi-ff8097116b
Bump sharp, next and @react-email/ui
2026-08-18 17:21:25 -04:00
dependabot[bot] 8e938a2723 Bump sharp, next and @react-email/ui
Bumps [sharp](https://github.com/lovell/sharp) to 0.35.3 and updates ancestor dependencies [sharp](https://github.com/lovell/sharp), [next](https://github.com/vercel/next.js) and [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui). These dependencies need to be updated together.


Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

Updates `next` from 16.2.11 to 16.3.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.11...v16.3.1)

Updates `@react-email/ui` from 6.5.0 to 6.9.2
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.9.2/packages/ui)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: indirect
- dependency-name: next
  dependency-version: 16.3.1
  dependency-type: direct:production
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 16:26:28 +00:00
Owen Schwartz 197f8f7ba5 Merge pull request #3503 from fosrl/dependabot/npm_and_yarn/engine.io-6.6.9
Bump engine.io from 6.6.7 to 6.6.9
2026-08-18 12:25:27 -04:00
Owen Schwartz 10b528642d Merge pull request #3517 from fosrl/dependabot/github_actions/github-actions-dependencies-7eb9e48ea9
Bump the github-actions-dependencies group across 1 directory with 2 updates
2026-08-18 12:24:54 -04:00
Owen Schwartz 60d6fff085 Merge pull request #3525 from fosrl/dependabot/npm_and_yarn/ip-address-10.4.0
Bump ip-address from 10.2.0 to 10.4.0
2026-08-18 12:24:30 -04:00
Owen Schwartz c664b3da91 Merge pull request #3526 from fosrl/dependabot/npm_and_yarn/multi-5e81c1b34f
Bump brace-expansion
2026-08-18 12:24:17 -04:00
Owen Schwartz 492282e758 Merge pull request #3530 from fosrl/dependabot/npm_and_yarn/socket.io-parser-4.2.7
Bump socket.io-parser from 4.2.6 to 4.2.7
2026-08-18 12:24:03 -04:00
Owen Schwartz 47f4aefc25 Merge pull request #3531 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.5
Bump fast-uri from 3.1.4 to 3.1.5
2026-08-18 12:23:43 -04:00
Owen Schwartz 7c0ff9ede7 Merge pull request #3569 from fosrl/dependabot/npm_and_yarn/js-yaml-4.3.1
Bump js-yaml from 4.3.0 to 4.3.1
2026-08-18 12:23:32 -04:00
Owen Schwartz 44e81ea979 Merge pull request #3570 from fosrl/dependabot/npm_and_yarn/nanoid-3.3.18
Bump nanoid from 3.3.12 to 3.3.18
2026-08-18 12:23:17 -04:00
Owen Schwartz 56dc10330a Merge pull request #3572 from fosrl/dependabot/npm_and_yarn/postcss-8.5.23
Bump postcss from 8.5.15 to 8.5.23
2026-08-18 12:23:03 -04:00
dependabot[bot] eb8ad6a181 Bump the github-actions-dependencies group across 1 directory with 2 updates
Bumps the github-actions-dependencies group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/stale](https://github.com/actions/stale).


Updates `docker/login-action` from 4.5.1 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

Updates `actions/stale` from 10.4.0 to 11.0.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 01:34:14 +00:00
Shlee 4edd2e4d32 Update requestPasswordReset.ts 2026-08-16 22:21:37 +09:30
Shlee 813c3abe54 Update requestPasswordReset.ts 2026-08-16 22:03:00 +09:30
Owen Schwartz 048e4fc73c Merge pull request #3574 from fosrl/dev
1.21.1-s.5
2026-08-12 16:34:31 -04:00
dependabot[bot] 923371e5b4 Bump postcss from 8.5.15 to 8.5.23
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 02:57:16 +00:00
dependabot[bot] 81430ba3d3 Bump nanoid from 3.3.12 to 3.3.18
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.12 to 3.3.18.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.12...3.3.18)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 23:54:36 +00:00
dependabot[bot] e4aaadc9f9 Bump js-yaml from 4.3.0 to 4.3.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 20:19:18 +00:00
Owen Schwartz d04740fede Merge pull request #3537 from fosrl/dev
1.21.1-s.4
2026-08-06 14:07:42 -04:00
dependabot[bot] 4677a0d501 Bump fast-uri from 3.1.4 to 3.1.5
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 18:18:41 +00:00
dependabot[bot] b4463f0e1a Bump socket.io-parser from 4.2.6 to 4.2.7
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 06:42:16 +00:00
Owen Schwartz b7c0669c38 Merge pull request #3528 from fosrl/dev
1.21.1-s.3
2026-08-04 17:46:49 -04:00
dependabot[bot] 152d2fb1d6 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.6 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

Updates `brace-expansion` from 1.1.14 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 05:33:39 +00:00
dependabot[bot] d374b4f66e Bump ip-address from 10.2.0 to 10.4.0
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 01:45:48 +00:00
dependabot[bot] 192542629f Bump engine.io from 6.6.7 to 6.6.9
Bumps [engine.io](https://github.com/socketio/socket.io) from 6.6.7 to 6.6.9.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.7...engine.io@6.6.9)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-version: 6.6.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 21:59:23 +00:00
76 changed files with 1069 additions and 2116 deletions
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
days-before-stale: 14
days-before-close: 14
+2 -2
View File
@@ -1,5 +1,5 @@
# FROM node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:26.7.0-slim AS base
WORKDIR /app
@@ -33,7 +33,7 @@ FROM base AS builder
RUN npm ci --omit=dev
# FROM node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:26.7.0-slim AS runner
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.1-alpine
FROM node:26.7.0-alpine
WORKDIR /app
+2 -67
View File
@@ -7,8 +7,6 @@ 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`
@@ -41,7 +39,6 @@ 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
@@ -50,10 +47,10 @@ set. Default capabilities do not overlap for native OpenAI vs Anthropic:
| Provider type | Default capabilities |
|---------------|----------------------|
| `openai` | `openai_chat`, `openai_responses` |
| `anthropic` | `anthropic_messages`, `v1_models` |
| `anthropic` | `anthropic_messages` |
| `openRouter` | `openai_chat` |
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages`, `v1_models` |
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
| `custom` | whatever was configured |
### 2. Allow / Block Lists
@@ -131,68 +128,6 @@ 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
-6
View File
@@ -1785,7 +1785,6 @@
"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",
@@ -1891,7 +1890,6 @@
"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",
@@ -1924,8 +1922,6 @@
"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",
@@ -4087,8 +4083,6 @@
"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",
+324 -281
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -94,12 +94,12 @@
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "16.2.11",
"next": "16.3.1",
"next-intl": "4.13.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
@@ -139,7 +139,7 @@
"devDependencies": {
"@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0",
"@react-email/ui": "^6.9.2",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13",
@@ -170,7 +170,7 @@
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.15",
"postcss": "8.5.23",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
@@ -182,6 +182,6 @@
"overrides": {
"esbuild": "0.28.0",
"dompurify": "3.4.0",
"postcss": "8.5.15"
"postcss": "8.5.23"
}
}
-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 413 B

-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 413 B

-3
View File
@@ -468,9 +468,6 @@ 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),
-3
View File
@@ -459,9 +459,6 @@ 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" })
+1 -16
View File
@@ -4,7 +4,7 @@ import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
export { AI_CAPABILITIES, type AiCapability };
export type AiCapabilityRoute = {
method: "GET" | "POST";
method: "POST";
path: string;
};
@@ -135,21 +135,6 @@ 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",
-3
View File
@@ -471,8 +471,6 @@ 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,
@@ -487,7 +485,6 @@ 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,
+23 -87
View File
@@ -44,20 +44,6 @@ 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;
@@ -67,20 +53,8 @@ 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(),
@@ -91,22 +65,6 @@ 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()
});
@@ -150,18 +108,6 @@ 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
}
};
}
@@ -338,44 +284,34 @@ 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 }[] {
return listCatalogEntriesForType(type, query).map((entry) => ({
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;
}
/**
-235
View File
@@ -1,235 +0,0 @@
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
};
}
-2
View File
@@ -335,8 +335,6 @@ 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
-2
View File
@@ -9,7 +9,6 @@ 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",
@@ -38,7 +37,6 @@ 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"],
+1 -4
View File
@@ -22,10 +22,7 @@ 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,
// Same as above: accepted for parity, unused since the OSS build has no
// remote exit nodes to exclude.
noRemote = false
siteId?: number
) {
// TODO: pick which nodes to send and ping better than just all of them that are not remote
const allExitNodes = await db
+1
View File
@@ -348,6 +348,7 @@ 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,16 +1,3 @@
/*
* 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 {
+2 -5
View File
@@ -153,8 +153,7 @@ export async function listExitNodes(
orgId: string,
filterOnline = false,
noCloud = false,
siteId?: number,
noRemote = false
siteId?: number
) {
const allExitNodes = await db
.select({
@@ -243,9 +242,7 @@ export async function listExitNodes(
let remoteExitNodesList = allExitNodes.filter(
(node) =>
node.type === "remoteExitNode" &&
!noRemote &&
(!filterOnline || node.online)
node.type === "remoteExitNode" && (!filterOnline || node.online)
);
const gerbilExitNodes = allExitNodes.filter(
(node) =>
@@ -19,8 +19,7 @@ import {
requestAuditLog,
actionAuditLog,
accessAuditLog,
connectionAuditLog,
aiSessionLog
connectionAuditLog
} from "@server/db";
import logger from "@server/logger";
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
@@ -310,7 +309,6 @@ 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;
@@ -587,13 +585,6 @@ 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(
@@ -679,21 +670,6 @@ 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 }
>;
}
}
@@ -718,14 +694,6 @@ 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 : "";
+2 -3
View File
@@ -15,14 +15,13 @@
// Log type identifiers
// ---------------------------------------------------------------------------
export type LogType = "request" | "action" | "access" | "connection" | "aiSession";
export type LogType = "request" | "action" | "access" | "connection";
export const LOG_TYPES: LogType[] = [
"request",
"action",
"access",
"connection",
"aiSession"
"connection"
];
// ---------------------------------------------------------------------------
@@ -1,288 +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 { 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,10 +291,6 @@ async function disableFeature(
await disableConnectionLogs(orgId);
break;
case TierFeature.AISessionLogs:
await disableAISessionLogs(orgId);
break;
case TierFeature.RotateCredentials:
await disableRotateCredentials(orgId);
break;
@@ -497,15 +493,6 @@ 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,8 +37,7 @@ 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),
sendAISessionLogs: z.boolean().optional().default(false)
sendAccessLogs: z.boolean().optional().default(false)
});
export type CreateEventStreamingDestinationResponse = {
@@ -123,8 +122,7 @@ export async function createEventStreamingDestination(
sendAccessLogs: parsedBody.data.sendAccessLogs,
sendActionLogs: parsedBody.data.sendActionLogs,
sendConnectionLogs: parsedBody.data.sendConnectionLogs,
sendRequestLogs: parsedBody.data.sendRequestLogs,
sendAISessionLogs: parsedBody.data.sendAISessionLogs
sendRequestLogs: parsedBody.data.sendRequestLogs
})
.returning();
@@ -60,7 +60,6 @@ export type ListEventStreamingDestinationsResponse = {
sendRequestLogs: boolean;
sendActionLogs: boolean;
sendAccessLogs: boolean;
sendAISessionLogs: boolean;
}[];
pagination: {
total: number;
@@ -84,8 +83,7 @@ const ListEventStreamingDestinationsResponseDataSchema = z.object({
sendConnectionLogs: z.boolean(),
sendRequestLogs: z.boolean(),
sendActionLogs: z.boolean(),
sendAccessLogs: z.boolean(),
sendAISessionLogs: z.boolean()
sendAccessLogs: z.boolean()
})
),
pagination: z.object({
@@ -40,8 +40,7 @@ const bodySchema = z.strictObject({
sendConnectionLogs: z.boolean().optional(),
sendRequestLogs: z.boolean().optional(),
sendActionLogs: z.boolean().optional(),
sendAccessLogs: z.boolean().optional(),
sendAISessionLogs: z.boolean().optional()
sendAccessLogs: z.boolean().optional()
});
export type UpdateEventStreamingDestinationResponse = {
@@ -126,7 +125,7 @@ export async function updateEventStreamingDestination(
);
}
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs, sendAISessionLogs } = parsedBody.data;
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
const updateData: Record<string, unknown> = {
updatedAt: Date.now()
@@ -142,7 +141,6 @@ 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)
-23
View File
@@ -21,10 +21,6 @@ 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";
@@ -595,25 +591,6 @@ 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,6 +34,10 @@ 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 ||
@@ -44,7 +48,7 @@ export async function createExitNode(
.insert(exitNodes)
.values({
publicKey,
endpoint: config.getRawConfig().gerbil.base_endpoint,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
-23
View File
@@ -43,10 +43,6 @@ 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";
@@ -157,25 +153,6 @@ 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,
@@ -0,0 +1,238 @@
/*
* 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})`
);
};
+1
View File
@@ -12,3 +12,4 @@
*/
export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";
+3 -1
View File
@@ -18,10 +18,12 @@ 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
};
+1 -1
View File
@@ -139,7 +139,7 @@ const processMessage = async (
}
}
} catch (error) {
logger.warn("Message handling error:", error);
logger.error("Message handling error:", error);
// ws.send(JSON.stringify({
// type: "error",
// data: {
@@ -1,37 +1,19 @@
import { Router, type Request, type Response } from "express";
import { Router } 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) {
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);
}
router.post(route.path, (req, res) =>
handleAiGatewayProxy(req, res, capability)
);
}
}
-1
View File
@@ -1,3 +1,2 @@
export { handleAiGatewayProxy } from "./pipeline";
export { handleV1Models } from "./v1Models";
export { createAiGatewayRouter } from "./createAiGatewayRouter";
+255 -5
View File
@@ -1,12 +1,171 @@
import { AiCapability } from "@app/lib/aiCapabilities";
import { AiProvider } from "@server/db";
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);
}
}
/**
* Gracefully flush all pending logs (call this on shutdown)
*/
export async function shutdownAiSessionLogger() {}
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 cleanUpOldLogs(orgId: string, retentionDays: number) {}
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;
@@ -22,4 +181,95 @@ export function logAiSession(data: {
siteResourceId: number | null;
requestUserId: string | null;
virtualApiKeyId: string | null;
}): void {}
}): 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 });
}
})();
}
+11 -20
View File
@@ -86,7 +86,7 @@ import {
type AiUsage
} from "@server/lib/aiUsageExtraction";
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
import { logAiSession } from "#dynamic/routers/aiGateway/logAiSession";
import { logAiSession } from "@server/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;
}
export type ProviderAttachment = {
type ProviderAttachment = {
provider: AiProvider;
accessMode: AccessMode;
};
@@ -149,12 +149,12 @@ type ResourceModelPattern = {
enabled: boolean;
};
export type ProviderPatternLists = {
type ProviderPatternLists = {
allows: string[];
blocks: string[];
};
export type ResolvedTarget = {
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.
export async function resolveTarget(
async function resolveTarget(
host: string,
resourceType: AiGatewayResourceType | null
): Promise<ResolvedTarget | null> {
@@ -728,9 +728,7 @@ 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 ?? ""),
@@ -812,17 +810,6 @@ 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,
@@ -831,7 +818,11 @@ export async function handleAiGatewayProxy(
try {
const def = AI_CAPABILITY_DEFS[capability];
const host = resolveGatewayHost(req);
const host = (
(req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy
req.headers.host ||
""
).split(":")[0];
if (!host) {
return res
.status(HttpCode.BAD_REQUEST)
@@ -99,7 +99,6 @@ async function fetchProviderTargets(
method: targets.method,
exitNodeSubnet: sites.exitNodeSubnet,
reachableAt: exitNodes.reachableAt,
exitNodeType: exitNodes.type,
hcHealth: targetHealthCheck.hcHealth
})
.from(targets)
@@ -120,12 +119,6 @@ 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
-308
View File
@@ -1,308 +0,0 @@
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"
);
}
}
+7 -2
View File
@@ -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 } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { createDate } from "oslo";
import logger from "@server/logger";
@@ -49,7 +49,12 @@ export async function requestPasswordReset(
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (!existingUser || !existingUser.length) {
await randomDelay(2000);
+15
View File
@@ -1490,6 +1490,21 @@ 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,
+7 -1
View File
@@ -15,7 +15,13 @@ 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 ||
@@ -26,7 +32,7 @@ export async function createExitNode(
.insert(exitNodes)
.values({
publicKey,
endpoint: config.getRawConfig().gerbil.base_endpoint,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
+15
View File
@@ -1532,6 +1532,21 @@ 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,248 +1,9 @@
/*
* 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) => {
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,8 +42,7 @@ export const handleOlmExitNodesRequestMessage: MessageHandler = async (
client.orgId,
true,
noCloud || false,
olm.clientId,
true // don't select remote exit nodes for clients
olm.clientId
); // filter for only the online ones
let lastExitNodeId = null;
-36
View File
@@ -147,42 +147,6 @@ 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);
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{resourceId}/clients/add",
path: "/private-resource/{siteResourceId}/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/{resourceId}/roles/add",
path: "/private-resource/{siteResourceId}/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/{resourceId}/users/add",
path: "/private-resource/{siteResourceId}/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/{resourceId}/clients",
path: "/private-resource/{siteResourceId}/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/{resourceId}/roles",
path: "/private-resource/{siteResourceId}/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/{resourceId}/users",
path: "/private-resource/{siteResourceId}/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/{resourceId}/clients/remove",
path: "/private-resource/{siteResourceId}/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/{resourceId}/roles/remove",
path: "/private-resource/{siteResourceId}/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/{resourceId}/users/remove",
path: "/private-resource/{siteResourceId}/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/{resourceId}/clients",
path: "/private-resource/{siteResourceId}/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/{resourceId}/roles",
path: "/private-resource/{siteResourceId}/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/{resourceId}/users",
path: "/private-resource/{siteResourceId}/users",
description:
"Set users for a site resource. This will replace all existing users.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
+1 -3
View File
@@ -7,8 +7,7 @@ import {
handleNewtExitNodesRequestMessage,
handleApplyBlueprintMessage,
handleNewtPingMessage,
handleNewtDisconnectingMessage,
handleRequestLogMessage
handleNewtDisconnectingMessage
} from "../newt";
import {
handleOlmRegisterMessage,
@@ -47,6 +46,5 @@ 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
};
+1 -1
View File
@@ -388,7 +388,7 @@ const setupConnection = async (
}
}
} catch (error) {
logger.warn("Message handling error:", error);
logger.error("Message handling error:", error);
ws.send(
JSON.stringify({
type: "error",
@@ -345,7 +345,6 @@ export default function AiProviderNetworkPage() {
ref={targetsFormRef}
orgId={orgId}
isHttp
isAiProvider
providerId={provider.providerId}
initialTargets={
isTargetModeSaved ? remoteTargets : []
@@ -682,7 +682,6 @@ export default function CreateAiProviderPage() {
<ProxyResourceTargetsForm
orgId={orgId}
isHttp
isAiProvider
onChange={(nextTargets) => {
targetsRef.current = nextTargets;
}}
@@ -298,6 +298,101 @@ 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
@@ -679,131 +774,6 @@ 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>
+1 -13
View File
@@ -3,13 +3,11 @@ 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";
@@ -17,8 +15,6 @@ 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";
@@ -33,7 +29,6 @@ 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)",
@@ -49,7 +44,6 @@ export default function AiSessionLogsPage() {
const searchParams = useSearchParams();
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
@@ -139,8 +133,7 @@ export default function AiSessionLogsPage() {
...logQueries.aiSessions({
orgId: orgId as string,
filters: queryFilters
}),
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
})
});
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
@@ -652,8 +645,6 @@ export default function AiSessionLogsPage() {
description={t("aiSessionLogsDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
{org.org.settingsLogRetentionDaysAISessions === 0 && (
<LogRetentionWarning
orgId={orgId as string}
@@ -688,9 +679,6 @@ export default function AiSessionLogsPage() {
pageSize={pageSize}
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
}
/>
</>
);
@@ -113,8 +113,6 @@ 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<
@@ -133,8 +131,7 @@ export const ProxyResourceTargetsForm = forwardRef<
emptyMessage,
embedded = false,
hideSaveButton = false,
disableAdvancedMode = false,
isAiProvider = false
disableAdvancedMode = false
},
ref
) {
@@ -262,14 +259,6 @@ 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) => {
@@ -983,7 +972,6 @@ 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">
@@ -1000,11 +988,6 @@ export const ProxyResourceTargetsForm = forwardRef<
.
</p>
)}
{build === "saas" && isAiProvider && hasRemoteExitNodes && (
<p className="text-sm text-muted-foreground mt-3">
{t("aiProviderRemoteNodeTargetsWarning")}
</p>
)}
</>
);
@@ -20,7 +20,6 @@ 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",
+1 -29
View File
@@ -57,7 +57,6 @@ export interface Destination {
sendActionLogs: boolean;
sendConnectionLogs: boolean;
sendRequestLogs: boolean;
sendAISessionLogs: boolean;
lastError: string | null;
lastErrorAt: number | null;
createdAt: number;
@@ -181,7 +180,6 @@ 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) {
@@ -192,7 +190,6 @@ export function HttpDestinationCredenza({
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
}
}, [open, editing]);
@@ -229,8 +226,7 @@ export function HttpDestinationCredenza({
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs,
sendAISessionLogs
sendRequestLogs
};
if (editing) {
await api.post(
@@ -782,30 +778,6 @@ 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>
+1 -28
View File
@@ -90,7 +90,6 @@ 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) {
@@ -99,7 +98,6 @@ export function S3DestinationCredenza({
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
setSendAISessionLogs(editing?.sendAISessionLogs ?? false);
}
}, [open, editing]);
@@ -123,8 +121,7 @@ export function S3DestinationCredenza({
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs,
sendAISessionLogs
sendRequestLogs
};
if (editing) {
await api.post(
@@ -513,30 +510,6 @@ 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,10 +49,6 @@ 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;
@@ -69,8 +65,7 @@ export function AiClientConfigSection({
const descriptions: Record<string, string> = {
claude: t("aiClientConfigDescriptionClaude"),
codex: t("aiClientConfigDescriptionCodex"),
opencode: t("aiClientConfigDescriptionOpencode"),
gemini: t("aiClientConfigDescriptionGemini")
opencode: t("aiClientConfigDescriptionOpencode")
};
return (
-1
View File
@@ -2,7 +2,6 @@ export const AI_CAPABILITIES = [
"openai_chat",
"openai_responses",
"anthropic_messages",
"v1_models",
"gemini_generate_content",
"bedrock_model_invoke",
"google_generate_content",
+11 -68
View File
@@ -1,16 +1,10 @@
export const AI_CLIENT_IDS = [
"claude",
"codex",
"opencode",
"gemini"
] as const;
export const AI_CLIENT_IDS = ["claude", "codex", "opencode"] as const;
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
export const AI_CLIENT_NAMES: Record<AiClientId, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
gemini: "Gemini CLI"
opencode: "OpenCode"
};
/** Auth as supplied by callers: the real key isn't fetched yet. */
@@ -49,15 +43,8 @@ export type AiClientGuide = {
presets: AiConfigPreset[];
};
/**
* Placeholder key for keyless (private/site) resources. Those resources need
* no credential, but most clients refuse to start without *some* key set, so
* they get an obviously-inert one rather than an omitted field.
*/
const KEYLESS_PLACEHOLDER_KEY = "none";
function keyValue(auth: AiClientAuth): string {
return auth.mode === "keyed" ? auth.key : KEYLESS_PLACEHOLDER_KEY;
return auth.mode === "keyed" ? auth.key : "-";
}
function block(
@@ -87,7 +74,7 @@ export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean {
}
function buildCli(
clientArg: "claude" | "codex" | "opencode" | "gemini",
clientArg: "claude" | "codex" | "opencode",
auth: AiClientAuth,
resourceNiceId?: string
): AiConfigBlock[] {
@@ -139,7 +126,7 @@ function buildClaudeGuide(
(key) =>
[
`export ANTHROPIC_BASE_URL=${endpoint}`,
`export ANTHROPIC_API_KEY=${key}`,
`export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`,
"claude"
].join("\n"),
auth
@@ -341,7 +328,7 @@ function buildOpencodeGuide(
"More providers",
() =>
"OpenCode configures providers individually, so Anthropic and OpenAI are just the ones set up above. " +
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and a matching key in auth.json.',
'You can point any other OpenCode-supported provider (e.g. "openrouter", "google", "groq") at this gateway the same way: add a matching entry under "provider" in opencode.json, and under auth.json if it needs an API key.',
auth,
"steps"
);
@@ -355,53 +342,10 @@ function buildOpencodeGuide(
id: "default",
label: "Default",
relation: "steps",
// auth.json is written even for keyless resources: OpenCode
// refuses to start a provider with no key at all ("OpenAI API
// key is missing"), so it gets the inert placeholder instead.
blocks: [config, authFile, moreProviders]
}
]
};
}
function buildGeminiGuide(
endpoint: string,
auth: AiClientAuth,
resourceNiceId?: string
): AiClientGuide {
const defaultEnv = block(
"gemini-default-env",
"~/.gemini/.env",
(key) =>
[
`GOOGLE_GEMINI_BASE_URL=${endpoint}`,
`GEMINI_API_KEY=${key}`
].join("\n"),
auth
);
const defaultShell = block(
"gemini-default-shell",
"Shell",
(key) =>
[
`export GOOGLE_GEMINI_BASE_URL=${endpoint}`,
`export GEMINI_API_KEY=${key}`,
"gemini"
].join("\n"),
auth
);
return {
id: "gemini",
name: AI_CLIENT_NAMES.gemini,
cli: buildCli("gemini", auth, resourceNiceId),
presets: [
{
id: "default",
label: "Default",
relation: "options",
blocks: [defaultEnv, defaultShell]
blocks:
auth.mode === "keyed"
? [config, authFile, moreProviders]
: [config, moreProviders]
}
]
};
@@ -417,8 +361,7 @@ const GUIDE_BUILDERS: Record<
> = {
claude: buildClaudeGuide,
codex: buildCodexGuide,
opencode: buildOpencodeGuide,
gemini: buildGeminiGuide
opencode: buildOpencodeGuide
};
export function buildAiClientGuide(
+3 -8
View File
@@ -38,12 +38,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
openai: {
upstreamUrl: "https://api.openai.com/v1",
authType: "bearer",
capabilities: ["openai_chat", "openai_responses", "v1_models"]
capabilities: ["openai_chat", "openai_responses"]
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "x-api-key",
capabilities: ["anthropic_messages", "v1_models"]
capabilities: ["anthropic_messages"]
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com",
@@ -63,12 +63,7 @@ export const AI_PROVIDER_DEFAULTS: Record<
microsoftFoundry: {
upstreamUrl: null,
authType: "bearer",
capabilities: [
"openai_chat",
"openai_responses",
"anthropic_messages",
"v1_models"
]
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
},
openRouter: {
upstreamUrl: "https://openrouter.ai/api/v1",
-12
View File
@@ -59,7 +59,6 @@ import type {
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
@@ -331,17 +330,6 @@ export const orgQueries = {
}
}),
remoteExitNodes: ({ orgId }: { orgId: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "REMOTE_EXIT_NODES"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListRemoteExitNodesResponse>
>(`/org/${orgId}/remote-exit-nodes`, { signal });
return res.data.data.remoteExitNodes;
}
}),
labels: ({
orgId,
query,