Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 177150086f | |||
| ff599ac7f1 | |||
| c256de50aa | |||
| ae2d0be2ea |
@@ -1467,6 +1467,237 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"title": "boolean",
|
"title": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"providers.profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "The profile list for copilot providers.\n@default []",
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"definitions": {
|
||||||
|
"CopilotManagedCapabilityToken": {
|
||||||
|
"enum": [
|
||||||
|
"chat",
|
||||||
|
"tools",
|
||||||
|
"vision",
|
||||||
|
"structured",
|
||||||
|
"embedding",
|
||||||
|
"rerank",
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"CopilotManagedModelConfigFile": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"capabilities": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CopilotManagedCapabilityToken"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"CopilotManagedProfileConfigFile": {
|
||||||
|
"properties": {
|
||||||
|
"apiKey": {
|
||||||
|
"default": null,
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"baseUrl": {
|
||||||
|
"default": null,
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"default": {},
|
||||||
|
"description": "Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"dialect": {
|
||||||
|
"default": null,
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"middleware": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/CopilotProviderMiddlewareConfigFile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CopilotManagedModelConfigFile"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"format": "double",
|
||||||
|
"type": [
|
||||||
|
"number",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/definitions/CopilotManagedProvider"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"CopilotManagedProvider": {
|
||||||
|
"enum": [
|
||||||
|
"anthropic",
|
||||||
|
"anthropicVertex",
|
||||||
|
"cloudflareWorkersAi",
|
||||||
|
"fal",
|
||||||
|
"gemini",
|
||||||
|
"geminiVertex",
|
||||||
|
"openai",
|
||||||
|
"openaiCompatible"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"CopilotNodeMiddlewareConfigFile": {
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CopilotNodeTextMiddleware"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"CopilotNodeTextMiddleware": {
|
||||||
|
"enum": [
|
||||||
|
"citation_footnote",
|
||||||
|
"callout",
|
||||||
|
"thinking_format"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"CopilotProviderMiddlewareConfigFile": {
|
||||||
|
"properties": {
|
||||||
|
"node": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/CopilotNodeMiddlewareConfigFile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rust": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/CopilotRustMiddlewareConfigFile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"CopilotRustMiddlewareConfigFile": {
|
||||||
|
"properties": {
|
||||||
|
"request": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CopilotRustRequestMiddleware"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CopilotRustStreamMiddleware"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"CopilotRustRequestMiddleware": {
|
||||||
|
"enum": [
|
||||||
|
"normalize_messages",
|
||||||
|
"clamp_max_tokens",
|
||||||
|
"tool_schema_rewrite",
|
||||||
|
"openai_request_compat",
|
||||||
|
"omit_tool_choice"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"CopilotRustStreamMiddleware": {
|
||||||
|
"enum": [
|
||||||
|
"stream_event_normalize",
|
||||||
|
"citation_indexing"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CopilotManagedProfileConfigFile"
|
||||||
|
},
|
||||||
|
"title": "Array_of_CopilotManagedProfileConfigFile",
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
/.yarn/unplugged
|
/.yarn/unplugged
|
||||||
/.yarn/install-state.gz
|
/.yarn/install-state.gz
|
||||||
/.pnp.*
|
/.pnp.*
|
||||||
|
**/dist
|
||||||
|
**/node_modules
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results
|
/test-results
|
||||||
|
|||||||
4
.gitea-deploy
Normal file
4
.gitea-deploy
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DOMAIN="gitea.luulam.dev"
|
||||||
|
REPO_PATH="luulam/affine"
|
||||||
|
PLATFORMS="linux/amd64,linux/arm64"
|
||||||
|
DOCKERFILE="./Dockerfile.all-in-one"
|
||||||
173
Dockerfile.all-in-one
Normal file
173
Dockerfile.all-in-one
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# =============================================================================
|
||||||
|
# AFFiNE all-in-one Docker build (self-contained, no prebuilt artifacts needed)
|
||||||
|
#
|
||||||
|
# Stages:
|
||||||
|
# 1. frontend - builds @affine/web + @affine/admin + @affine/mobile dists
|
||||||
|
# 2. native - cross-compiles @affine/server-native (Rust) per TARGETARCH
|
||||||
|
# 3. server-dist - bundles @affine/server (rspack) -> dist/main.js
|
||||||
|
# 4. deps - installs production node_modules (multi-arch aware)
|
||||||
|
# 5. runtime - assembles final image, mirrors .github/deployment/node/Dockerfile
|
||||||
|
#
|
||||||
|
# Build: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.all-in-one .
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Shared base args ---------------------------------------------------------
|
||||||
|
FROM node:22-bookworm-slim AS base
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends jq python3 make g++ && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy everything the yarn install / builds need (respects .dockerignore).
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
|
# Yarn 4 via corepack; keep scripts off like CI (.yarnrc.yml enableScripts=false)
|
||||||
|
RUN corepack enable && corepack prepare yarn@4.18.0 --activate
|
||||||
|
|
||||||
|
# Full dependency install for build stages.
|
||||||
|
# Prisma postinstall generates client for the BUILD platform only; the runtime
|
||||||
|
# stage re-generates it for the target platform (see `deps` stage).
|
||||||
|
RUN yarn install --immutable
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 1: frontend dists (web + admin + mobile)
|
||||||
|
# =============================================================================
|
||||||
|
FROM base AS frontend
|
||||||
|
|
||||||
|
# Self-hosted bundle: force publicPath '/' so assets are served from this
|
||||||
|
# container instead of affineassets CDN (html-plugin getPublicPath()).
|
||||||
|
ENV BUILD_TYPE=stable \
|
||||||
|
PUBLIC_PATH=/ \
|
||||||
|
NODE_ENV=production \
|
||||||
|
GITHUB_SHA=dockerselfhost
|
||||||
|
|
||||||
|
RUN yarn affine @affine/web build && \
|
||||||
|
yarn affine @affine/admin build && \
|
||||||
|
yarn affine @affine/mobile build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 2: Rust native module (@affine/server-native), per target arch
|
||||||
|
# =============================================================================
|
||||||
|
FROM base AS native
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Rust toolchain (minimal profile; extra targets added per-arch below).
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends curl ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:${PATH}
|
||||||
|
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain 1.97.1 --profile minimal && \
|
||||||
|
cargo --version && rustc --version
|
||||||
|
ENV CARGO_PROFILE_RELEASE_LTO=thin \
|
||||||
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 \
|
||||||
|
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
|
||||||
|
CARGO_PROFILE_RELEASE_STRIP=symbols
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /app/packages/backend/native
|
||||||
|
|
||||||
|
# Cross-compile with Debian's cross-GCC instead of napi-rs' bundled GCC 4.8.5:
|
||||||
|
# gcc 4.8.5 does not define __ARM_ARCH for aarch64, breaking aws-lc-sys asm.
|
||||||
|
RUN if [ "$TARGETARCH" = "arm64" ]; then \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
rustup target add aarch64-unknown-linux-gnu && \
|
||||||
|
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
|
||||||
|
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
|
||||||
|
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||||
|
yarn build --target aarch64-unknown-linux-gnu && \
|
||||||
|
# index.js resolves arch-specific names; rspack bundles all of them.
|
||||||
|
cp server-native.node server-native.arm64.node && \
|
||||||
|
cp server-native.node server-native.x64.node && \
|
||||||
|
cp server-native.node server-native.armv7.node; \
|
||||||
|
else \
|
||||||
|
yarn build --target x86_64-unknown-linux-gnu && \
|
||||||
|
# Only server-native.node is present from napi; provide arch aliases so
|
||||||
|
# rspack can resolve index.js's arch-specific requires (docker-clean
|
||||||
|
# prunes the wrong-arch binary at runtime anyway).
|
||||||
|
cp server-native.node server-native.x64.node && \
|
||||||
|
cp server-native.node server-native.arm64.node && \
|
||||||
|
cp server-native.node server-native.armv7.node; \
|
||||||
|
fi && \
|
||||||
|
ls -la server-native*.node
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 3: server bundle (dist/main.js)
|
||||||
|
# =============================================================================
|
||||||
|
FROM base AS server-dist
|
||||||
|
COPY --from=native /app/packages/backend/native/*.node /app/packages/backend/native/
|
||||||
|
|
||||||
|
RUN yarn workspace @affine/server build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 4: production node_modules (per target arch)
|
||||||
|
# Reuse base (full install already done & cached), then prune to server prod
|
||||||
|
# deps. supportedArchitectures covers both cpus so optional native deps for
|
||||||
|
# either arch get fetched.
|
||||||
|
# =============================================================================
|
||||||
|
FROM base AS deps
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN yarn config set --json supportedArchitectures.cpu "[\"x64\", \"arm64\"]" && \
|
||||||
|
yarn config set --json supportedArchitectures.libc "[\"glibc\"]"
|
||||||
|
|
||||||
|
# Re-install with both cpu targets so the image works on amd64+arm64 builds.
|
||||||
|
RUN yarn install --immutable && \
|
||||||
|
yarn workspaces focus @affine/server @types/affine__env --production
|
||||||
|
|
||||||
|
# Regenerate prisma client engines for this build platform's node_modules
|
||||||
|
RUN cd packages/backend/server && yarn prisma generate
|
||||||
|
|
||||||
|
# Move to server dir layout expected by docker-clean.mjs & runtime stage
|
||||||
|
RUN mv /app/node_modules /app/packages/backend/server/node_modules
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 5: runtime — mirror .github/deployment/node/Dockerfile layout
|
||||||
|
# =============================================================================
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/packages/backend/server/node_modules /app/node_modules
|
||||||
|
COPY --from=server-dist /app/packages/backend/server/dist /app/dist
|
||||||
|
COPY --from=server-dist /app/packages/backend/server/scripts /app/scripts
|
||||||
|
COPY --from=frontend /app/packages/frontend/apps/web/dist /app/static
|
||||||
|
COPY --from=frontend /app/packages/frontend/admin/dist /app/static/admin
|
||||||
|
COPY --from=frontend /app/packages/frontend/apps/mobile/dist /app/static/mobile
|
||||||
|
# Mirror upstream layout: /app IS the server package dir — the selfhost
|
||||||
|
# predeploy job runs `yarn prisma migrate deploy` + `yarn cli` from here.
|
||||||
|
COPY --from=base /app/packages/backend/server/package.json /app/package.json
|
||||||
|
COPY --from=base /app/packages/backend/server/schema.prisma /app/schema.prisma
|
||||||
|
COPY --from=base /app/packages/backend/server/migrations /app/migrations
|
||||||
|
|
||||||
|
# Upstream installs these in the final image (jemalloc for ENV below,
|
||||||
|
# openssl for prisma engines).
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends openssl libjemalloc2 && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
# Native binding for this target arch. node-loader emitted
|
||||||
|
# `require('./server-native.node')` inside dist/main.js — the binary must sit
|
||||||
|
# next to it.
|
||||||
|
COPY --from=native /app/packages/backend/native/server-native.node /app/dist/server-native.node
|
||||||
|
|
||||||
|
ENV LD_PRELOAD=libjemalloc.so.2
|
||||||
|
|
||||||
|
|
||||||
|
# Same pruning as upstream: sourcemaps, wrong-arch natives/prisma engines,
|
||||||
|
# dedupe static files.
|
||||||
|
RUN AFFINE_DOCKER_CLEAN=1 \
|
||||||
|
TARGETARCH="$( [ "$TARGETARCH" = "amd64" ] && echo amd64 || echo arm64 )" \
|
||||||
|
node ./scripts/docker-clean.mjs
|
||||||
|
|
||||||
|
EXPOSE 3010
|
||||||
|
|
||||||
|
CMD ["node", "./dist/main.js"]
|
||||||
60
docker-compose.gitea.yml
Normal file
60
docker-compose.gitea.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# AFFiNE self-hosted — image built from Dockerfile.all-in-one (MCP 1.1.0)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose up -d
|
||||||
|
# open http://localhost:3010
|
||||||
|
services:
|
||||||
|
affine:
|
||||||
|
image: gitea.luulam.dev/luulam/affine:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3010:3010'
|
||||||
|
# Image entrypoint does not run migrations; do it once on first boot
|
||||||
|
command: >
|
||||||
|
sh -c "node node_modules/prisma/build/index.js migrate deploy &&
|
||||||
|
node ./dist/main.js"
|
||||||
|
environment:
|
||||||
|
# All-in-one flavor runs sync + graphql + front in one process
|
||||||
|
- FLAVOR=allinone
|
||||||
|
- DATABASE_URL=postgresql://affine:affine@postgres:5432/affine
|
||||||
|
- REDIS_SERVER_HOST=redis
|
||||||
|
- REDIS_SERVER_PORT=6379
|
||||||
|
# Signer for auth tokens (auth) — change in production!
|
||||||
|
- AFFINE_SECRET=change-me-to-a-random-string
|
||||||
|
# Blob storage on local disk
|
||||||
|
- AFFINE_STORAGE_LOCAL=true
|
||||||
|
volumes:
|
||||||
|
- affine-storage:/app/storage
|
||||||
|
- affine-config:/app/config
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=affine
|
||||||
|
- POSTGRES_PASSWORD=affine
|
||||||
|
- POSTGRES_DB=affine
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U affine -d affine']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
|
affine-storage:
|
||||||
|
affine-config:
|
||||||
1
packages/backend/native/index.d.ts
vendored
1
packages/backend/native/index.d.ts
vendored
@@ -87,6 +87,7 @@ export declare class BackendRuntime {
|
|||||||
rotateByokCredential(input: RotateByokCredentialInput): Promise<ByokProfileOutput>
|
rotateByokCredential(input: RotateByokCredentialInput): Promise<ByokProfileOutput>
|
||||||
probeByokProfile(input: ProbeByokProfileInput): Promise<ByokProbeResultOutput>
|
probeByokProfile(input: ProbeByokProfileInput): Promise<ByokProbeResultOutput>
|
||||||
probeByokDraft(input: ProbeByokDraftInput): Promise<ByokProbeResultOutput>
|
probeByokDraft(input: ProbeByokDraftInput): Promise<ByokProbeResultOutput>
|
||||||
|
probeManagedCopilotProfile(profileId: string, checks: Array<ByokProbeCheckInput>): Promise<ByokProbeResultOutput>
|
||||||
deleteByokProfile(workspaceId: string, profileId: string): Promise<boolean>
|
deleteByokProfile(workspaceId: string, profileId: string): Promise<boolean>
|
||||||
reorderByokProfiles(input: ReorderByokProfilesInput): Promise<Array<ByokProfileOutput>>
|
reorderByokProfiles(input: ReorderByokProfilesInput): Promise<Array<ByokProfileOutput>>
|
||||||
createByokLocalLease(input: CreateByokLocalLeaseInput): Promise<ByokLocalLeaseOutput>
|
createByokLocalLease(input: CreateByokLocalLeaseInput): Promise<ByokLocalLeaseOutput>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod probe;
|
|||||||
mod profile;
|
mod profile;
|
||||||
|
|
||||||
pub(super) use local::{LocalLeasePayload, create as create_local_lease};
|
pub(super) use local::{LocalLeasePayload, create as create_local_lease};
|
||||||
|
pub(in crate::runtime::backend_runtime) use probe::execute_probe_with_policy;
|
||||||
pub(super) use profile::{create, delete, list, probe_draft, probe_profile, reorder, replace, rotate};
|
pub(super) use profile::{create, delete, list, probe_draft, probe_profile, reorder, replace, rotate};
|
||||||
use profile::{envelope_key, require_text};
|
use profile::{envelope_key, require_text};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ pub(super) async fn execute_probe(
|
|||||||
credential: SensitiveCredential,
|
credential: SensitiveCredential,
|
||||||
policy: &ByokPolicy,
|
policy: &ByokPolicy,
|
||||||
checks: Vec<ByokProbeCheckInput>,
|
checks: Vec<ByokProbeCheckInput>,
|
||||||
|
) -> RuntimeResult<ByokProbeResultOutput> {
|
||||||
|
execute_probe_with_policy(
|
||||||
|
provider,
|
||||||
|
definition,
|
||||||
|
credential,
|
||||||
|
policy.egress_policy(&definition.endpoint),
|
||||||
|
checks,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe with an explicit egress policy. Managed (admin-configured) profiles
|
||||||
|
/// pass `AllowPrivate` for self-hosted endpoints; workspace BYOK always
|
||||||
|
/// derives the policy from [`ByokPolicy`].
|
||||||
|
pub(in crate::runtime::backend_runtime) async fn execute_probe_with_policy(
|
||||||
|
provider: &str,
|
||||||
|
definition: &ByokProfileDefinition,
|
||||||
|
credential: SensitiveCredential,
|
||||||
|
egress_policy: EgressPolicy,
|
||||||
|
checks: Vec<ByokProbeCheckInput>,
|
||||||
) -> RuntimeResult<ByokProbeResultOutput> {
|
) -> RuntimeResult<ByokProbeResultOutput> {
|
||||||
let tested_at_ms = chrono::Utc::now().timestamp_millis();
|
let tested_at_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let mut requested = Vec::new();
|
let mut requested = Vec::new();
|
||||||
@@ -71,7 +91,6 @@ pub(super) async fn execute_probe(
|
|||||||
let credential = String::from_utf8(credential.expose().to_vec())
|
let credential = String::from_utf8(credential.expose().to_vec())
|
||||||
.map_err(|_| RuntimeError::invalid_state("credential_unavailable"))?;
|
.map_err(|_| RuntimeError::invalid_state("credential_unavailable"))?;
|
||||||
let operation_for_task = operation.clone();
|
let operation_for_task = operation.clone();
|
||||||
let egress_policy = policy.egress_policy(&endpoint);
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
dispatch_check(
|
dispatch_check(
|
||||||
&provider,
|
&provider,
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ fn load_managed_profiles(
|
|||||||
.providers
|
.providers
|
||||||
.profiles
|
.profiles
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|profile| profile.enabled && profile.models.iter().any(|model| model == model_id))
|
.filter(|profile| profile.enabled && profile.models.iter().any(|model| model.id == *model_id))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let Some(profile) = matches.first() else {
|
let Some(profile) = matches.first() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -204,15 +204,23 @@ fn load_managed_profiles(
|
|||||||
"built-in managed route model matches multiple profiles",
|
"built-in managed route model matches multiple profiles",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let capabilities = provider_default_capability_upper_bound(&profile.provider, model_id)
|
let declared = matches
|
||||||
.ok_or_else(|| RuntimeError::invalid_state("built-in managed route model is incompatible with its profile"))?;
|
.first()
|
||||||
|
.and_then(|profile| profile.models.iter().find(|model| model.id == *model_id))
|
||||||
|
.and_then(|model| model.capabilities.clone());
|
||||||
|
let capabilities = match declared {
|
||||||
|
Some(capabilities) => capabilities,
|
||||||
|
None => provider_default_capability_upper_bound(&profile.provider, model_id).ok_or_else(|| {
|
||||||
|
RuntimeError::invalid_state("built-in managed route model is incompatible with its profile")
|
||||||
|
})?,
|
||||||
|
};
|
||||||
let endpoint = managed_endpoint(profile)?;
|
let endpoint = managed_endpoint(profile)?;
|
||||||
Ok(Some(AuthorizedProviderProfile {
|
Ok(Some(AuthorizedProviderProfile {
|
||||||
profile_id: profile.id.clone(),
|
profile_id: profile.id.clone(),
|
||||||
source: ProfileSource::Managed,
|
source: ProfileSource::Managed,
|
||||||
provider: profile.provider.clone(),
|
provider: profile.provider.clone(),
|
||||||
endpoint,
|
endpoint,
|
||||||
openai_dialect: (profile.provider == "openai").then_some(OpenAiDialect::Responses),
|
openai_dialect: openai_dialect_for(profile),
|
||||||
egress_policy: llm_adapter::target::EgressPolicy::PublicOnly,
|
egress_policy: llm_adapter::target::EgressPolicy::PublicOnly,
|
||||||
models: vec![crate::llm::byok::ByokModelDeclaration {
|
models: vec![crate::llm::byok::ByokModelDeclaration {
|
||||||
model_id: model_id.clone(),
|
model_id: model_id.clone(),
|
||||||
@@ -229,6 +237,19 @@ fn load_managed_profiles(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn openai_dialect_for(profile: &CopilotManagedProfileConfig) -> Option<OpenAiDialect> {
|
||||||
|
match profile.provider.as_str() {
|
||||||
|
"openai" => Some(OpenAiDialect::Responses),
|
||||||
|
"openaiCompatible" => Some(
|
||||||
|
match profile.config.get("dialect").and_then(serde_json::Value::as_str) {
|
||||||
|
Some("responses") => OpenAiDialect::Responses,
|
||||||
|
_ => OpenAiDialect::ChatCompletions,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn managed_endpoint(profile: &CopilotManagedProfileConfig) -> RuntimeResult<BackendEndpoint> {
|
fn managed_endpoint(profile: &CopilotManagedProfileConfig) -> RuntimeResult<BackendEndpoint> {
|
||||||
if let Some(base_url) = profile.config.get("baseURL").and_then(serde_json::Value::as_str) {
|
if let Some(base_url) = profile.config.get("baseURL").and_then(serde_json::Value::as_str) {
|
||||||
return llm_adapter::target::canonicalize_endpoint(base_url)
|
return llm_adapter::target::canonicalize_endpoint(base_url)
|
||||||
@@ -315,14 +336,18 @@ pub(super) fn required_config_text<'a>(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{BackendEndpoint, CopilotManagedProfileConfig, managed_endpoint};
|
use super::{BackendEndpoint, CopilotManagedProfileConfig, managed_endpoint, openai_dialect_for};
|
||||||
|
use crate::runtime::config::CopilotManagedModel;
|
||||||
|
|
||||||
fn vertex_profile(location: &str) -> CopilotManagedProfileConfig {
|
fn vertex_profile(location: &str) -> CopilotManagedProfileConfig {
|
||||||
CopilotManagedProfileConfig {
|
CopilotManagedProfileConfig {
|
||||||
id: "vertex".to_string(),
|
id: "vertex".to_string(),
|
||||||
provider: "geminiVertex".to_string(),
|
provider: "geminiVertex".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
models: vec!["gemini-3.7-flash".to_string()],
|
models: vec![CopilotManagedModel {
|
||||||
|
id: "gemini-3.7-flash".to_string(),
|
||||||
|
capabilities: None,
|
||||||
|
}],
|
||||||
config: json!({ "project": "affine-us", "location": location }),
|
config: json!({ "project": "affine-us", "location": location }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,4 +372,32 @@ mod tests {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compatible_dialect_defaults_to_chat_completions() {
|
||||||
|
let profile = CopilotManagedProfileConfig {
|
||||||
|
id: "vllm".to_string(),
|
||||||
|
provider: "openaiCompatible".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
models: vec![CopilotManagedModel {
|
||||||
|
id: "qwen3-32b".to_string(),
|
||||||
|
capabilities: None,
|
||||||
|
}],
|
||||||
|
config: json!({ "baseURL": "http://127.0.0.1:8000/v1", "apiKey": "x" }),
|
||||||
|
};
|
||||||
|
assert!(managed_endpoint(&profile).is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
openai_dialect_for(&profile),
|
||||||
|
Some(llm_adapter::target::OpenAiDialect::ChatCompletions)
|
||||||
|
);
|
||||||
|
|
||||||
|
let profile = CopilotManagedProfileConfig {
|
||||||
|
config: json!({ "dialect": "responses" }),
|
||||||
|
..profile
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
openai_dialect_for(&profile),
|
||||||
|
Some(llm_adapter::target::OpenAiDialect::Responses)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ pub(super) async fn create_vertex_token_provider(
|
|||||||
pub(in crate::runtime::backend_runtime) fn provider(value: &str) -> RuntimeResult<BackendProvider> {
|
pub(in crate::runtime::backend_runtime) fn provider(value: &str) -> RuntimeResult<BackendProvider> {
|
||||||
match value {
|
match value {
|
||||||
"openai" => Ok(BackendProvider::OpenAi),
|
"openai" => Ok(BackendProvider::OpenAi),
|
||||||
|
"openaiCompatible" => Ok(BackendProvider::OpenAi),
|
||||||
"anthropic" => Ok(BackendProvider::Anthropic),
|
"anthropic" => Ok(BackendProvider::Anthropic),
|
||||||
"anthropicVertex" => Ok(BackendProvider::AnthropicVertex),
|
"anthropicVertex" => Ok(BackendProvider::AnthropicVertex),
|
||||||
"gemini" => Ok(BackendProvider::Gemini),
|
"gemini" => Ok(BackendProvider::Gemini),
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use llm_adapter::target::EgressPolicy;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
llm::{
|
||||||
|
ByokProbeCheckInput, ByokProbeResultOutput,
|
||||||
|
byok::{ByokEndpoint, SensitiveCredential},
|
||||||
|
},
|
||||||
|
runtime::{BackendRuntimeConfig, CopilotManagedProfileConfig, RuntimeError, RuntimeResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build a BYOK-equivalent definition from a managed profile so the shared
|
||||||
|
/// probe engine can compile targets and dispatch real requests. Managed
|
||||||
|
/// profiles are admin-controlled, so private endpoints (self-hosted vLLM,
|
||||||
|
/// Ollama, LiteLLM) are allowed and no DNS admission check applies.
|
||||||
|
pub(super) fn managed_definition(
|
||||||
|
managed: &CopilotManagedProfileConfig,
|
||||||
|
) -> RuntimeResult<(crate::llm::byok::ByokProfileDefinition, EgressPolicy)> {
|
||||||
|
use crate::llm::byok::{ByokModelDeclaration, ByokProfileDefinition};
|
||||||
|
|
||||||
|
let endpoint = if let Some(base_url) = managed.config.get("baseURL").and_then(serde_json::Value::as_str) {
|
||||||
|
ByokEndpoint::OpenAiCompatible {
|
||||||
|
url: llm_adapter::target::canonicalize_endpoint(base_url)
|
||||||
|
.map_err(|error| RuntimeError::invalid_state(error.to_string()))?,
|
||||||
|
dialect: openai_dialect(managed).unwrap_or(llm_adapter::target::OpenAiDialect::ChatCompletions),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ByokEndpoint::ProviderDefault
|
||||||
|
};
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
let models = managed
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|model| {
|
||||||
|
if !ids.insert(model.id.clone()) {
|
||||||
|
return Err(RuntimeError::invalid_state(
|
||||||
|
"managed copilot profile models must be unique",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(ByokModelDeclaration {
|
||||||
|
model_id: model.id.clone(),
|
||||||
|
enabled: true,
|
||||||
|
capabilities: model.capabilities.clone().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<RuntimeResult<Vec<_>>>()?;
|
||||||
|
let egress_policy = if matches!(endpoint, ByokEndpoint::OpenAiCompatible { .. }) {
|
||||||
|
EgressPolicy::AllowPrivate
|
||||||
|
} else {
|
||||||
|
EgressPolicy::PublicOnly
|
||||||
|
};
|
||||||
|
Ok((ByokProfileDefinition { endpoint, models }, egress_policy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_dialect(managed: &CopilotManagedProfileConfig) -> Option<llm_adapter::target::OpenAiDialect> {
|
||||||
|
match managed.provider.as_str() {
|
||||||
|
"openai" => Some(llm_adapter::target::OpenAiDialect::Responses),
|
||||||
|
"openaiCompatible" => Some(
|
||||||
|
match managed.config.get("dialect").and_then(serde_json::Value::as_str) {
|
||||||
|
Some("responses") => llm_adapter::target::OpenAiDialect::Responses,
|
||||||
|
_ => llm_adapter::target::OpenAiDialect::ChatCompletions,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe a managed profile from the active runtime config without touching
|
||||||
|
/// per-workspace BYOK storage. Used by the admin console "Test" action.
|
||||||
|
pub(in crate::runtime::backend_runtime) async fn probe_managed(
|
||||||
|
config: &BackendRuntimeConfig,
|
||||||
|
profile_id: &str,
|
||||||
|
checks: Vec<ByokProbeCheckInput>,
|
||||||
|
) -> RuntimeResult<ByokProbeResultOutput> {
|
||||||
|
let managed = super::context::managed_profile(&config.copilot, profile_id)?;
|
||||||
|
let (definition, egress_policy) = managed_definition(managed)?;
|
||||||
|
// Vertex credentials need a token provider; static providers read config.
|
||||||
|
if matches!(managed.provider.as_str(), "geminiVertex" | "anthropicVertex") {
|
||||||
|
return Err(RuntimeError::invalid_input(
|
||||||
|
"probe for vertex managed profiles is not supported; use workspace BYOK",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let credential = super::dispatch::managed_credential(managed, None).await?;
|
||||||
|
crate::runtime::backend_runtime::byok::execute_probe_with_policy(
|
||||||
|
&managed.provider,
|
||||||
|
&definition,
|
||||||
|
SensitiveCredential::new(credential.into_bytes()),
|
||||||
|
egress_policy,
|
||||||
|
checks,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod context;
|
mod context;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
|
pub(in crate::runtime::backend_runtime) mod managed_probe;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ pub(super) use super::{
|
|||||||
napi_error, to_napi_error, webpki_tls_config,
|
napi_error, to_napi_error, webpki_tls_config,
|
||||||
};
|
};
|
||||||
use crate::llm::{
|
use crate::llm::{
|
||||||
ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeResultOutput, ByokProfileOutput, CreateByokLocalLeaseInput,
|
ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeCheckInput, ByokProbeResultOutput, ByokProfileOutput,
|
||||||
CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput, ReorderByokProfilesInput,
|
CreateByokLocalLeaseInput, CreateByokProfileInput, ProbeByokDraftInput, ProbeByokProfileInput,
|
||||||
ReplaceByokProfileInput, RotateByokCredentialInput,
|
ReorderByokProfilesInput, ReplaceByokProfileInput, RotateByokCredentialInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn token_hash(token: &str) -> String {
|
pub(super) fn token_hash(token: &str) -> String {
|
||||||
@@ -710,6 +710,18 @@ impl BackendRuntime {
|
|||||||
.map_err(to_napi_error)
|
.map_err(to_napi_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub async fn probe_managed_copilot_profile(
|
||||||
|
&self,
|
||||||
|
profile_id: String,
|
||||||
|
checks: Vec<ByokProbeCheckInput>,
|
||||||
|
) -> Result<ByokProbeResultOutput> {
|
||||||
|
let config = self.config()?;
|
||||||
|
copilot::managed_probe::probe_managed(&config, &profile_id, checks)
|
||||||
|
.await
|
||||||
|
.map_err(to_napi_error)
|
||||||
|
}
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub async fn delete_byok_profile(&self, workspace_id: String, profile_id: String) -> Result<bool> {
|
pub async fn delete_byok_profile(&self, workspace_id: String, profile_id: String) -> Result<bool> {
|
||||||
let deleted = byok::delete(&self.pool().await?, &workspace_id, &profile_id)
|
let deleted = byok::delete(&self.pool().await?, &workspace_id, &profile_id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::{
|
|||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use llm_adapter::capability::provider_default_capability_upper_bound;
|
use llm_adapter::capability::{DeclaredModelCapability, ModelFeature, provider_default_capability_upper_bound};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Map;
|
use serde_json::Map;
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
@@ -99,9 +99,7 @@ impl ConfigSource {
|
|||||||
self.exact() || self.override_path.as_deref() == Some(path)
|
self.exact() || self.override_path.as_deref() == Some(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Default)]
|
||||||
#[derive(Clone, Default, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", default)]
|
|
||||||
pub(crate) struct CopilotRuntimeConfig {
|
pub(crate) struct CopilotRuntimeConfig {
|
||||||
pub(crate) enabled: bool,
|
pub(crate) enabled: bool,
|
||||||
pub(crate) byok: CopilotByokRuntimeConfig,
|
pub(crate) byok: CopilotByokRuntimeConfig,
|
||||||
@@ -135,25 +133,26 @@ fn default_allowed_providers() -> Vec<String> {
|
|||||||
SUPPORTED_BYOK_PROVIDERS.into_iter().map(str::to_string).collect()
|
SUPPORTED_BYOK_PROVIDERS.into_iter().map(str::to_string).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize)]
|
#[derive(Clone, Default)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
|
||||||
pub(crate) struct CopilotProvidersRuntimeConfig {
|
pub(crate) struct CopilotProvidersRuntimeConfig {
|
||||||
pub(crate) profiles: Vec<CopilotManagedProfileConfig>,
|
pub(crate) profiles: Vec<CopilotManagedProfileConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct CopilotManagedProfileConfig {
|
pub(crate) struct CopilotManagedProfileConfig {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub(crate) provider: String,
|
pub(crate) provider: String,
|
||||||
#[serde(default = "enabled_by_default")]
|
|
||||||
pub(crate) enabled: bool,
|
pub(crate) enabled: bool,
|
||||||
#[serde(default)]
|
pub(crate) models: Vec<CopilotManagedModel>,
|
||||||
pub(crate) models: Vec<String>,
|
|
||||||
pub(crate) config: serde_json::Value,
|
pub(crate) config: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct CopilotManagedModel {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) capabilities: Option<Vec<DeclaredModelCapability>>,
|
||||||
|
}
|
||||||
|
|
||||||
fn enabled_by_default() -> bool {
|
fn enabled_by_default() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -182,11 +181,44 @@ pub(crate) struct CopilotManagedProfileConfigFile {
|
|||||||
priority: Option<f64>,
|
priority: Option<f64>,
|
||||||
#[serde(default = "enabled_by_default")]
|
#[serde(default = "enabled_by_default")]
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
models: Option<Vec<String>>,
|
#[serde(default)]
|
||||||
|
base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
api_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
dialect: Option<String>,
|
||||||
|
models: Option<Vec<CopilotManagedModelConfigFile>>,
|
||||||
middleware: Option<CopilotProviderMiddlewareConfigFile>,
|
middleware: Option<CopilotProviderMiddlewareConfigFile>,
|
||||||
|
/// Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields.
|
||||||
|
#[serde(default)]
|
||||||
config: Map<String, serde_json::Value>,
|
config: Map<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub(crate) enum CopilotManagedModelConfigFile {
|
||||||
|
Id(String),
|
||||||
|
Declared {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
capabilities: Vec<CopilotManagedCapabilityToken>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Deserialize, serde::Serialize, schemars::JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) enum CopilotManagedCapabilityToken {
|
||||||
|
Chat,
|
||||||
|
Tools,
|
||||||
|
Vision,
|
||||||
|
Structured,
|
||||||
|
Embedding,
|
||||||
|
Rerank,
|
||||||
|
Image,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Deserialize, serde::Serialize, schemars::JsonSchema)]
|
#[derive(Clone, Copy, Deserialize, serde::Serialize, schemars::JsonSchema)]
|
||||||
enum CopilotManagedProvider {
|
enum CopilotManagedProvider {
|
||||||
#[serde(rename = "anthropic")]
|
#[serde(rename = "anthropic")]
|
||||||
@@ -203,6 +235,8 @@ enum CopilotManagedProvider {
|
|||||||
GeminiVertex,
|
GeminiVertex,
|
||||||
#[serde(rename = "openai")]
|
#[serde(rename = "openai")]
|
||||||
OpenAi,
|
OpenAi,
|
||||||
|
#[serde(rename = "openaiCompatible")]
|
||||||
|
OpenAiCompatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CopilotManagedProvider {
|
impl CopilotManagedProvider {
|
||||||
@@ -215,12 +249,14 @@ impl CopilotManagedProvider {
|
|||||||
Self::Gemini => "gemini",
|
Self::Gemini => "gemini",
|
||||||
Self::GeminiVertex => "geminiVertex",
|
Self::GeminiVertex => "geminiVertex",
|
||||||
Self::OpenAi => "openai",
|
Self::OpenAi => "openai",
|
||||||
|
Self::OpenAiCompatible => "openaiCompatible",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legacy_models(self) -> Vec<String> {
|
fn legacy_models(self) -> Vec<String> {
|
||||||
let models: &[&str] = match self {
|
let models: &[&str] = match self {
|
||||||
Self::OpenAi => &["gpt-5.6-luna", "gpt-5.6-terra", "gpt-image-1", "gpt-4o-mini"],
|
Self::OpenAi => &["gpt-5.6-luna", "gpt-5.6-terra", "gpt-image-1", "gpt-4o-mini"],
|
||||||
|
Self::OpenAiCompatible => &[],
|
||||||
Self::CloudflareWorkersAi => &["@cf/baai/bge-reranker-base"],
|
Self::CloudflareWorkersAi => &["@cf/baai/bge-reranker-base"],
|
||||||
Self::Fal => &["lora/image-to-image", "workflowutils/teed"],
|
Self::Fal => &["lora/image-to-image", "workflowutils/teed"],
|
||||||
Self::Gemini => &["gemini-3.7-flash", "gemini-embedding-001"],
|
Self::Gemini => &["gemini-3.7-flash", "gemini-embedding-001"],
|
||||||
@@ -273,6 +309,65 @@ enum CopilotNodeTextMiddleware {
|
|||||||
ThinkingFormat,
|
ThinkingFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
|
||||||
|
type Error = RuntimeError;
|
||||||
|
|
||||||
|
fn try_from(value: CopilotManagedProfileConfigFile) -> Result<Self, Self::Error> {
|
||||||
|
if value.id.is_empty()
|
||||||
|
|| !value
|
||||||
|
.id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||||
|
{
|
||||||
|
return Err(RuntimeError::invalid_state(
|
||||||
|
"managed copilot profile id must contain only letters, numbers, hyphens, and underscores",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let models = value.models.unwrap_or_else(|| {
|
||||||
|
value
|
||||||
|
.provider
|
||||||
|
.legacy_models()
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| CopilotManagedModelConfigFile::Id(id))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let models = models
|
||||||
|
.into_iter()
|
||||||
|
.map(|model| match model {
|
||||||
|
CopilotManagedModelConfigFile::Id(id) => Ok(CopilotManagedModel { id, capabilities: None }),
|
||||||
|
CopilotManagedModelConfigFile::Declared {
|
||||||
|
id,
|
||||||
|
enabled: _,
|
||||||
|
capabilities,
|
||||||
|
} => {
|
||||||
|
let capabilities = (!capabilities.is_empty())
|
||||||
|
.then(|| capabilities.iter().map(managed_capability).collect())
|
||||||
|
.transpose()?;
|
||||||
|
Ok(CopilotManagedModel { id, capabilities })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<RuntimeResult<Vec<_>>>()?;
|
||||||
|
let mut config = value.config;
|
||||||
|
if let Some(base_url) = value.base_url {
|
||||||
|
config.insert("baseURL".to_string(), serde_json::Value::String(base_url));
|
||||||
|
}
|
||||||
|
if let Some(api_key) = value.api_key {
|
||||||
|
config.insert("apiKey".to_string(), serde_json::Value::String(api_key));
|
||||||
|
}
|
||||||
|
if let Some(dialect) = value.dialect {
|
||||||
|
config.insert("dialect".to_string(), serde_json::Value::String(dialect));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
id: value.id,
|
||||||
|
provider: value.provider.as_str().to_string(),
|
||||||
|
enabled: value.enabled,
|
||||||
|
models,
|
||||||
|
config: serde_json::Value::Object(config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
|
impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
|
||||||
type Error = RuntimeError;
|
type Error = RuntimeError;
|
||||||
|
|
||||||
@@ -292,29 +387,62 @@ impl TryFrom<CopilotRuntimeConfigFile> for CopilotRuntimeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
|
fn managed_capability(token: &CopilotManagedCapabilityToken) -> RuntimeResult<DeclaredModelCapability> {
|
||||||
type Error = RuntimeError;
|
use llm_adapter::capability::{AttachmentKind, AttachmentSource, ModelInput, ModelOutput};
|
||||||
|
let capability = match token {
|
||||||
fn try_from(value: CopilotManagedProfileConfigFile) -> Result<Self, Self::Error> {
|
CopilotManagedCapabilityToken::Chat => DeclaredModelCapability {
|
||||||
if value.id.is_empty()
|
input: vec![ModelInput::Text],
|
||||||
|| !value
|
output: vec![ModelOutput::Text],
|
||||||
.id
|
features: vec![],
|
||||||
.bytes()
|
attachment_kinds: vec![],
|
||||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
attachment_sources: vec![],
|
||||||
{
|
},
|
||||||
return Err(RuntimeError::invalid_state(
|
CopilotManagedCapabilityToken::Tools => DeclaredModelCapability {
|
||||||
"managed copilot profile id must contain only letters, numbers, hyphens, and underscores",
|
input: vec![ModelInput::Text],
|
||||||
));
|
output: vec![ModelOutput::Text],
|
||||||
}
|
features: vec![ModelFeature::ToolCalling],
|
||||||
let models = value.models.unwrap_or_else(|| value.provider.legacy_models());
|
attachment_kinds: vec![],
|
||||||
Ok(Self {
|
attachment_sources: vec![],
|
||||||
id: value.id,
|
},
|
||||||
provider: value.provider.as_str().to_string(),
|
CopilotManagedCapabilityToken::Vision => DeclaredModelCapability {
|
||||||
enabled: value.enabled,
|
input: vec![ModelInput::Text, ModelInput::Image],
|
||||||
models,
|
output: vec![ModelOutput::Text],
|
||||||
config: serde_json::Value::Object(value.config),
|
features: vec![],
|
||||||
})
|
attachment_kinds: vec![AttachmentKind::Image],
|
||||||
}
|
attachment_sources: vec![AttachmentSource::Url, AttachmentSource::Data, AttachmentSource::Bytes],
|
||||||
|
},
|
||||||
|
CopilotManagedCapabilityToken::Structured => DeclaredModelCapability {
|
||||||
|
input: vec![ModelInput::Text],
|
||||||
|
output: vec![ModelOutput::Structured],
|
||||||
|
features: vec![],
|
||||||
|
attachment_kinds: vec![],
|
||||||
|
attachment_sources: vec![],
|
||||||
|
},
|
||||||
|
CopilotManagedCapabilityToken::Embedding => DeclaredModelCapability {
|
||||||
|
input: vec![ModelInput::Text],
|
||||||
|
output: vec![ModelOutput::Embedding],
|
||||||
|
features: vec![],
|
||||||
|
attachment_kinds: vec![],
|
||||||
|
attachment_sources: vec![],
|
||||||
|
},
|
||||||
|
CopilotManagedCapabilityToken::Rerank => DeclaredModelCapability {
|
||||||
|
input: vec![ModelInput::Text],
|
||||||
|
output: vec![ModelOutput::Rerank],
|
||||||
|
features: vec![],
|
||||||
|
attachment_kinds: vec![],
|
||||||
|
attachment_sources: vec![],
|
||||||
|
},
|
||||||
|
CopilotManagedCapabilityToken::Image => DeclaredModelCapability {
|
||||||
|
input: vec![ModelInput::Text],
|
||||||
|
output: vec![ModelOutput::Image],
|
||||||
|
features: vec![],
|
||||||
|
attachment_kinds: vec![],
|
||||||
|
attachment_sources: vec![],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
llm_adapter::capability::validate_declared_capability(&capability)
|
||||||
|
.map(|_| capability)
|
||||||
|
.map_err(|error| RuntimeError::invalid_state(format!("managed copilot profile capability invalid: {error}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -459,12 +587,17 @@ pub(super) fn validate_copilot_config(config: &CopilotRuntimeConfig) -> RuntimeR
|
|||||||
}
|
}
|
||||||
let mut models = std::collections::HashSet::new();
|
let mut models = std::collections::HashSet::new();
|
||||||
for model in &profile.models {
|
for model in &profile.models {
|
||||||
if model.trim().is_empty() || !models.insert(model.as_str()) {
|
if model.id.trim().is_empty() || !models.insert(model.id.as_str()) {
|
||||||
return Err(RuntimeError::invalid_state(
|
return Err(RuntimeError::invalid_state(
|
||||||
"managed copilot profile models must be non-empty and unique",
|
"managed copilot profile models must be non-empty and unique",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
provider_default_capability_upper_bound(&profile.provider, model)
|
// openaiCompatible endpoints serve arbitrary model ids; declared or
|
||||||
|
// probed capabilities replace the built-in catalog lookup.
|
||||||
|
if profile.provider == "openaiCompatible" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
provider_default_capability_upper_bound(&profile.provider, &model.id)
|
||||||
.ok_or_else(|| RuntimeError::invalid_state("managed copilot profile model is unsupported"))?;
|
.ok_or_else(|| RuntimeError::invalid_state("managed copilot profile model is unsupported"))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,7 +987,14 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
|
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
|
||||||
validate_copilot_config(&copilot).unwrap();
|
validate_copilot_config(&copilot).unwrap();
|
||||||
assert_eq!(copilot.providers.profiles[0].models, expected_models);
|
assert_eq!(
|
||||||
|
copilot.providers.profiles[0]
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
expected_models
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let app_config = app_config_from_flat_overrides([(
|
let app_config = app_config_from_flat_overrides([(
|
||||||
@@ -1025,4 +1165,60 @@ mod tests {
|
|||||||
Some("workspace_invitation")
|
Some("workspace_invitation")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compatible_profile_accepts_arbitrary_models_and_flat_fields() {
|
||||||
|
let app_config = app_config_from_module_json(serde_json::json!({
|
||||||
|
"copilot": {
|
||||||
|
"enabled": true,
|
||||||
|
"providers": {
|
||||||
|
"profiles": [{
|
||||||
|
"id": "my-vllm",
|
||||||
|
"type": "openaiCompatible",
|
||||||
|
"baseUrl": "http://127.0.0.1:8000/v1",
|
||||||
|
"apiKey": "sk-test",
|
||||||
|
"models": [
|
||||||
|
"qwen3-32b",
|
||||||
|
{ "id": "bge-m3", "capabilities": ["embedding"] }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let copilot: CopilotRuntimeConfig = app_config.copilot.unwrap().try_into().unwrap();
|
||||||
|
validate_copilot_config(&copilot).unwrap();
|
||||||
|
|
||||||
|
let profile = &copilot.providers.profiles[0];
|
||||||
|
assert_eq!(profile.provider, "openaiCompatible");
|
||||||
|
assert_eq!(profile.models.len(), 2);
|
||||||
|
assert_eq!(profile.models[0].id, "qwen3-32b");
|
||||||
|
assert!(profile.models[0].capabilities.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
profile.config.get("baseURL").and_then(serde_json::Value::as_str),
|
||||||
|
Some("http://127.0.0.1:8000/v1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
profile.config.get("apiKey").and_then(serde_json::Value::as_str),
|
||||||
|
Some("sk-test")
|
||||||
|
);
|
||||||
|
|
||||||
|
let declared = profile.models[1].capabilities.as_ref().unwrap();
|
||||||
|
use llm_adapter::capability::ModelOutput;
|
||||||
|
assert!(
|
||||||
|
declared
|
||||||
|
.iter()
|
||||||
|
.any(|capability| capability.output.contains(&ModelOutput::Embedding))
|
||||||
|
);
|
||||||
|
|
||||||
|
// unknown provider types are rejected at deserialization, not validation
|
||||||
|
let app_config = app_config_from_flat_overrides([(
|
||||||
|
"copilot.providers.profiles",
|
||||||
|
serde_json::json!([{ "id": "x", "type": "totally-unknown-provider", "models": ["any"] }]),
|
||||||
|
)]);
|
||||||
|
assert!(
|
||||||
|
app_config.is_err(),
|
||||||
|
"unknown provider type must be rejected at deserialization"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fn descriptors() -> Vec<AppConfigDescriptor> {
|
|||||||
description: "The profile list for copilot providers.".to_string(),
|
description: "The profile list for copilot providers.".to_string(),
|
||||||
default_value: json!(defaults.providers.profiles),
|
default_value: json!(defaults.providers.profiles),
|
||||||
schema: schema_for::<Vec<CopilotManagedProfileConfigFile>>(),
|
schema: schema_for::<Vec<CopilotManagedProfileConfigFile>>(),
|
||||||
internal: true,
|
internal: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(descriptors[0].default_value, json!(true));
|
assert_eq!(descriptors[0].default_value, json!(true));
|
||||||
assert!(descriptors[4].internal);
|
assert!(!descriptors[4].internal);
|
||||||
assert!(
|
assert!(
|
||||||
validate_app_config_value(
|
validate_app_config_value(
|
||||||
"copilot".to_string(),
|
"copilot".to_string(),
|
||||||
@@ -218,4 +218,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compatible_profiles_validate_with_flat_fields_and_arbitrary_models() {
|
||||||
|
assert!(
|
||||||
|
validate_app_config_value(
|
||||||
|
"copilot".to_string(),
|
||||||
|
"providers.profiles".to_string(),
|
||||||
|
json!([{
|
||||||
|
"id": "my-vllm",
|
||||||
|
"type": "openaiCompatible",
|
||||||
|
"enabled": true,
|
||||||
|
"baseUrl": "http://127.0.0.1:8000/v1",
|
||||||
|
"apiKey": "sk-test",
|
||||||
|
"dialect": "chat_completions",
|
||||||
|
"models": ["qwen3-32b", { "id": "bge-m3", "capabilities": ["embedding"] }]
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
// unknown model ids on catalog providers still fail
|
||||||
|
assert!(
|
||||||
|
!validate_app_config_value(
|
||||||
|
"copilot".to_string(),
|
||||||
|
"providers.profiles".to_string(),
|
||||||
|
json!([{ "id": "x", "type": "openai", "models": ["not-in-catalog"] }]),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Models } from '../../models';
|
|||||||
import { CopilotFeatureService } from '../../plugins/copilot/feature';
|
import { CopilotFeatureService } from '../../plugins/copilot/feature';
|
||||||
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
|
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
|
||||||
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
|
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
|
||||||
import { installMockCopilotRuntime } from '../mocks';
|
import { installMockCopilotRuntime, Mockers } from '../mocks';
|
||||||
import { createTestingApp, createWorkspace, type TestingApp } from '../utils';
|
import { createTestingApp, createWorkspace, type TestingApp } from '../utils';
|
||||||
import {
|
import {
|
||||||
chatWithImages,
|
chatWithImages,
|
||||||
@@ -250,7 +250,14 @@ test('MCP credentials remain endpoint-bound through rotate, revoke and expiry',
|
|||||||
(await provider.for(user.id, target.id, McpAccessMode.READ_ONLY)).tools.map(
|
(await provider.for(user.id, target.id, McpAccessMode.READ_ONLY)).tools.map(
|
||||||
tool => tool.name
|
tool => tool.name
|
||||||
),
|
),
|
||||||
['read_document', 'doc_search']
|
[
|
||||||
|
'read_document',
|
||||||
|
'doc_search',
|
||||||
|
'list_documents',
|
||||||
|
'get_workspace_info',
|
||||||
|
'get_users',
|
||||||
|
'get_comments',
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const rotated = await credentials.rotate(
|
const rotated = await credentials.rotate(
|
||||||
@@ -283,3 +290,129 @@ test('MCP credentials remain endpoint-bound through rotate, revoke and expiry',
|
|||||||
});
|
});
|
||||||
await t.throwsAsync(credentials.authenticate(disabled.token, target.id));
|
await t.throwsAsync(credentials.authenticate(disabled.token, target.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MCP server exposes full toolset with read-write credential and exercises doc/comment tools', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const auth = app.get(AuthService);
|
||||||
|
const credentials = app.get(McpCredentialService);
|
||||||
|
const provider = app.get(WorkspaceMcpProvider);
|
||||||
|
const user = await auth.signUp(`mcp-rw-${randomUUID()}@affine.pro`, '123456');
|
||||||
|
const workspace = await app.create(Mockers.Workspace, {
|
||||||
|
owner: user,
|
||||||
|
snapshot: true,
|
||||||
|
});
|
||||||
|
const issued = await credentials.create({
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name: 'RW client',
|
||||||
|
accessMode: McpAccessMode.READ_WRITE,
|
||||||
|
expirationDays: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
// READ_WRITE no longer gated behind dev/canary
|
||||||
|
const toolNames = (
|
||||||
|
await provider.for(user.id, workspace.id, McpAccessMode.READ_WRITE)
|
||||||
|
).tools.map(tool => tool.name);
|
||||||
|
t.deepEqual(toolNames, [
|
||||||
|
'read_document',
|
||||||
|
'doc_search',
|
||||||
|
'create_document',
|
||||||
|
'update_document',
|
||||||
|
'update_document_meta',
|
||||||
|
'list_documents',
|
||||||
|
'get_workspace_info',
|
||||||
|
'create_comment',
|
||||||
|
'get_users',
|
||||||
|
'get_comments',
|
||||||
|
]);
|
||||||
|
const callTool = async (name: string, args: Record<string, unknown>) => {
|
||||||
|
const response = await app
|
||||||
|
.POST(`/api/workspaces/${workspace.id}/mcp`)
|
||||||
|
.set('Authorization', `Bearer ${issued.token}`)
|
||||||
|
.send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name, arguments: args },
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const result = response.body.result as {
|
||||||
|
content?: { text: string }[];
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
t.like(
|
||||||
|
{ error: result.isError ? result.content?.[0].text : null },
|
||||||
|
{ error: null }
|
||||||
|
);
|
||||||
|
t.truthy(result.content);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// create_document
|
||||||
|
const created = await callTool('create_document', {
|
||||||
|
title: 'MCP Test Doc',
|
||||||
|
content: '# MCP Test Doc\n\nHello from MCP.',
|
||||||
|
});
|
||||||
|
const docId = JSON.parse(created.content![0].text).docId as string;
|
||||||
|
const listed = await callTool('list_documents', {});
|
||||||
|
const docs = JSON.parse(listed.content![0].text).docs as {
|
||||||
|
doc_id: string;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
t.log('list_documents result:', listed.content?.[0].text);
|
||||||
|
t.true(
|
||||||
|
docs.some(doc => doc.doc_id === docId && doc.title === 'MCP Test Doc')
|
||||||
|
);
|
||||||
|
|
||||||
|
// get_workspace_info
|
||||||
|
const wsInfo = await callTool('get_workspace_info', {});
|
||||||
|
t.like(JSON.parse(wsInfo.content![0].text), {
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
member_count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// get_users contains owner
|
||||||
|
const users = await callTool('get_users', {});
|
||||||
|
const userList = JSON.parse(users.content![0].text).users as { id: string }[];
|
||||||
|
t.true(userList.some(u => u.id === user.id));
|
||||||
|
|
||||||
|
// create_comment + get_comments roundtrip
|
||||||
|
const commented = await callTool('create_comment', {
|
||||||
|
docId,
|
||||||
|
content: 'A comment from MCP',
|
||||||
|
});
|
||||||
|
const commentId = JSON.parse(commented.content![0].text).comment_id as string;
|
||||||
|
t.truthy(commentId);
|
||||||
|
|
||||||
|
const comments = await callTool('get_comments', { docId });
|
||||||
|
const parsed = JSON.parse(comments.content![0].text) as {
|
||||||
|
comments: { id: string; content: unknown }[];
|
||||||
|
};
|
||||||
|
t.is(parsed.comments.length, 1);
|
||||||
|
t.is(parsed.comments[0].id, commentId);
|
||||||
|
t.like(parsed.comments[0].content, {
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'A comment from MCP' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MCP read-only credential cannot create documents or comments', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const auth = app.get(AuthService);
|
||||||
|
const models = app.get(Models);
|
||||||
|
const provider = app.get(WorkspaceMcpProvider);
|
||||||
|
const user = await auth.signUp(`mcp-ro-${randomUUID()}@affine.pro`, '123456');
|
||||||
|
const workspace = await models.workspace.create(user.id);
|
||||||
|
|
||||||
|
const readOnlyTools = (
|
||||||
|
await provider.for(user.id, workspace.id, McpAccessMode.READ_ONLY)
|
||||||
|
).tools.map(tool => tool.name);
|
||||||
|
t.deepEqual(readOnlyTools.sort(), [
|
||||||
|
'doc_search',
|
||||||
|
'get_comments',
|
||||||
|
'get_users',
|
||||||
|
'get_workspace_info',
|
||||||
|
'list_documents',
|
||||||
|
'read_document',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -629,6 +629,15 @@ export class BackendRuntimeProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async probeManagedProfile(
|
||||||
|
profileId: string,
|
||||||
|
checks: Array<{ modelId: string; operation: string }>
|
||||||
|
): Promise<ByokProbeResultOutput> {
|
||||||
|
return await this.measured('probeManagedProfile', runtime =>
|
||||||
|
runtime.probeManagedCopilotProfile(profileId, checks)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteByokProfile(workspaceId: string, profileId: string) {
|
async deleteByokProfile(workspaceId: string, profileId: string) {
|
||||||
return await this.measured('deleteByokProfile', runtime =>
|
return await this.measured('deleteByokProfile', runtime =>
|
||||||
runtime.deleteByokProfile(workspaceId, profileId)
|
runtime.deleteByokProfile(workspaceId, profileId)
|
||||||
|
|||||||
@@ -35,13 +35,17 @@ type CopilotProviderProfileCommon = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
models?: string[];
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
dialect?: 'responses' | 'chat_completions';
|
||||||
|
models?: Array<string | { id: string; capabilities?: string[] }>;
|
||||||
middleware?: ProviderMiddlewareConfig;
|
middleware?: ProviderMiddlewareConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CopilotProviderProfile = CopilotProviderProfileCommon & {
|
export type CopilotProviderProfile = CopilotProviderProfileCommon & {
|
||||||
type: CopilotProviderType;
|
type: CopilotProviderType;
|
||||||
config: ProviderSpecificConfig;
|
/** Legacy nested form. New configs use the flat baseUrl/apiKey/dialect fields. */
|
||||||
|
config?: ProviderSpecificConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Args,
|
||||||
|
Field,
|
||||||
|
ID,
|
||||||
|
InputType,
|
||||||
|
Mutation,
|
||||||
|
ObjectType,
|
||||||
|
Resolver,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { Throttle } from '../../base';
|
||||||
|
import { BackendRuntimeProvider } from '../../core/backend-runtime';
|
||||||
|
import { Admin } from '../../core/common';
|
||||||
|
import { ByokProbeOperation, ByokProbeStatusKind } from './byok/types';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class ManagedProfileProbeCheckInput {
|
||||||
|
@Field(() => String)
|
||||||
|
modelId!: string;
|
||||||
|
|
||||||
|
@Field(() => ByokProbeOperation)
|
||||||
|
operation!: ByokProbeOperation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class ManagedProfileProbeStatusType {
|
||||||
|
@Field(() => ByokProbeStatusKind)
|
||||||
|
kind!: ByokProbeStatusKind;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
testedAt!: Date | null;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
errorKind!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class ManagedProfileModelProbeCheckType {
|
||||||
|
@Field(() => ByokProbeOperation)
|
||||||
|
operation!: ByokProbeOperation;
|
||||||
|
|
||||||
|
@Field(() => ManagedProfileProbeStatusType)
|
||||||
|
status!: ManagedProfileProbeStatusType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class ManagedProfileModelProbeType {
|
||||||
|
@Field(() => String)
|
||||||
|
modelId!: string;
|
||||||
|
|
||||||
|
@Field(() => [ManagedProfileModelProbeCheckType])
|
||||||
|
checks!: ManagedProfileModelProbeCheckType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class ManagedProfileProbeResultType {
|
||||||
|
@Field(() => String)
|
||||||
|
definitionFingerprint!: string;
|
||||||
|
|
||||||
|
@Field(() => ManagedProfileProbeStatusType)
|
||||||
|
connection!: ManagedProfileProbeStatusType;
|
||||||
|
|
||||||
|
@Field(() => [ManagedProfileModelProbeType])
|
||||||
|
models!: ManagedProfileModelProbeType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectStatus(probe: {
|
||||||
|
kind: string;
|
||||||
|
testedAtMs?: number;
|
||||||
|
errorKind?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
kind: probe.kind,
|
||||||
|
testedAt: probe.testedAtMs ? new Date(probe.testedAtMs) : null,
|
||||||
|
errorKind: probe.errorKind ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only surface for testing server-managed copilot provider profiles
|
||||||
|
* (copilot.providers.profiles) configured through the admin console.
|
||||||
|
*/
|
||||||
|
@Admin()
|
||||||
|
@Resolver(() => ManagedProfileProbeResultType)
|
||||||
|
export class ManagedCopilotProfileResolver {
|
||||||
|
constructor(private readonly runtime: BackendRuntimeProvider) {}
|
||||||
|
|
||||||
|
@Mutation(() => ManagedProfileProbeResultType, {
|
||||||
|
description:
|
||||||
|
'Test a server-managed copilot provider profile by dispatching real probe requests.',
|
||||||
|
})
|
||||||
|
@Throttle('strict')
|
||||||
|
async probeManagedCopilotProfile(
|
||||||
|
@Args('profileId', { type: () => ID }) profileId: string,
|
||||||
|
@Args('checks', { type: () => [ManagedProfileProbeCheckInput] })
|
||||||
|
checks: ManagedProfileProbeCheckInput[]
|
||||||
|
) {
|
||||||
|
const result = await this.runtime.probeManagedProfile(
|
||||||
|
profileId,
|
||||||
|
checks.map(check => ({
|
||||||
|
modelId: check.modelId,
|
||||||
|
operation: check.operation,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
definitionFingerprint: result.definitionFingerprint,
|
||||||
|
stale: result.stale,
|
||||||
|
connection: projectStatus(result.connection),
|
||||||
|
models: result.models.map(model => ({
|
||||||
|
modelId: model.modelId,
|
||||||
|
checks: model.checks.map(check => ({
|
||||||
|
operation: check.operation,
|
||||||
|
status: projectStatus(check.status),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { McpAccessMode } from '@prisma/client';
|
import { McpAccessMode } from '@prisma/client';
|
||||||
import z from 'zod/v3';
|
import z from 'zod/v3';
|
||||||
|
|
||||||
|
import { PaginationInput } from '../../../base/graphql';
|
||||||
import { DocReader, DocWriter } from '../../../core/doc';
|
import { DocReader, DocWriter } from '../../../core/doc';
|
||||||
import { PermissionAccess } from '../../../core/permission';
|
import { PermissionAccess, PermissionService } from '../../../core/permission';
|
||||||
|
import { readAllDocIdsFromWorkspaceSnapshot } from '../../../core/utils/blocksuite';
|
||||||
|
import { Models, WorkspaceRole } from '../../../models';
|
||||||
import { DocumentRetrievalService } from '../retrieval/document';
|
import { DocumentRetrievalService } from '../retrieval/document';
|
||||||
|
|
||||||
type McpTextContent = {
|
type McpTextContent = {
|
||||||
@@ -100,7 +103,9 @@ export class WorkspaceMcpProvider {
|
|||||||
private readonly ac: PermissionAccess,
|
private readonly ac: PermissionAccess,
|
||||||
private readonly reader: DocReader,
|
private readonly reader: DocReader,
|
||||||
private readonly writer: DocWriter,
|
private readonly writer: DocWriter,
|
||||||
private readonly retrieval: DocumentRetrievalService
|
private readonly retrieval: DocumentRetrievalService,
|
||||||
|
private readonly models: Models,
|
||||||
|
private readonly permission: PermissionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async for(
|
async for(
|
||||||
@@ -202,10 +207,7 @@ export class WorkspaceMcpProvider {
|
|||||||
|
|
||||||
const tools = [readDocument, docSearch];
|
const tools = [readDocument, docSearch];
|
||||||
|
|
||||||
if (
|
if (accessMode === McpAccessMode.READ_WRITE) {
|
||||||
accessMode === McpAccessMode.READ_WRITE &&
|
|
||||||
(env.dev || env.namespaces.canary)
|
|
||||||
) {
|
|
||||||
const createDocument = defineTool({
|
const createDocument = defineTool({
|
||||||
name: 'create_document',
|
name: 'create_document',
|
||||||
title: 'Create Document',
|
title: 'Create Document',
|
||||||
@@ -388,9 +390,297 @@ export class WorkspaceMcpProvider {
|
|||||||
tools.push(createDocument, updateDocument, updateDocumentMeta);
|
tools.push(createDocument, updateDocument, updateDocumentMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listDocuments = defineTool({
|
||||||
|
name: 'list_documents',
|
||||||
|
title: 'List Documents',
|
||||||
|
description:
|
||||||
|
'List documents in the workspace the credential owner can read, ordered by last update time (newest first). Returns doc IDs, titles and timestamps for pagination.',
|
||||||
|
parser: z.object({
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
|
}),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
||||||
|
offset: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
execute: async ({ limit, offset }, options) => {
|
||||||
|
await this.ac
|
||||||
|
.user(userId)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.assert('Workspace.Read');
|
||||||
|
|
||||||
|
const abortedAfterPermission = abortIfNeeded(options.signal);
|
||||||
|
if (abortedAfterPermission) return abortedAfterPermission;
|
||||||
|
|
||||||
|
const pagination: PaginationInput = {
|
||||||
|
first: Math.min(limit ?? 20, 100),
|
||||||
|
offset: offset ?? 0,
|
||||||
|
};
|
||||||
|
const rootDoc = await this.reader.getDoc(workspaceId, workspaceId);
|
||||||
|
if (!rootDoc) {
|
||||||
|
return toolText(
|
||||||
|
JSON.stringify({ total: 0, offset: pagination.offset, docs: [] })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const docIds = readAllDocIdsFromWorkspaceSnapshot(rootDoc.bin);
|
||||||
|
const readable = await this.permission.filterReadableDocs({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
docs: docIds.map(docId => ({ docId })),
|
||||||
|
});
|
||||||
|
const infos = (
|
||||||
|
await Promise.all(
|
||||||
|
readable.map(async ({ docId }) => {
|
||||||
|
const info = await this.models.doc.getDocInfo(workspaceId, docId);
|
||||||
|
if (!info || !info.title) {
|
||||||
|
// Doc created but its updates have not been merged into a
|
||||||
|
// snapshot yet, so `workspace_pages` has no title. Parse the
|
||||||
|
// title from the pending yjs binary instead.
|
||||||
|
const markdown = await this.reader.getDocMarkdown(
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (!markdown) return null;
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
docId,
|
||||||
|
title: markdown.title,
|
||||||
|
createdAt: info?.createdAt ?? new Date(),
|
||||||
|
updatedAt: info?.updatedAt ?? new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter(
|
||||||
|
(info): info is NonNullable<typeof info> =>
|
||||||
|
info !== null && !!info.title
|
||||||
|
);
|
||||||
|
infos.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
|
);
|
||||||
|
const page = infos.slice(
|
||||||
|
pagination.offset,
|
||||||
|
pagination.offset + pagination.first
|
||||||
|
);
|
||||||
|
return toolText(
|
||||||
|
JSON.stringify({
|
||||||
|
total: infos.length,
|
||||||
|
offset: pagination.offset,
|
||||||
|
docs: page.map(info => ({
|
||||||
|
doc_id: info.docId,
|
||||||
|
title: info.title,
|
||||||
|
created_at: info.createdAt,
|
||||||
|
updated_at: info.updatedAt,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tools.push(listDocuments);
|
||||||
|
|
||||||
|
const getWorkspaceInfo = defineTool({
|
||||||
|
name: 'get_workspace_info',
|
||||||
|
title: 'Get Workspace Info',
|
||||||
|
description: 'Get the name and member count of the workspace.',
|
||||||
|
parser: z.object({}),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
execute: async (_args, options) => {
|
||||||
|
await this.ac
|
||||||
|
.user(userId)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.assert('Workspace.Read');
|
||||||
|
|
||||||
|
const aborted = abortIfNeeded(options.signal);
|
||||||
|
if (aborted) return aborted;
|
||||||
|
|
||||||
|
const [workspace, memberCount] = await Promise.all([
|
||||||
|
this.models.workspace.get(workspaceId),
|
||||||
|
this.models.workspaceUser.count(workspaceId),
|
||||||
|
]);
|
||||||
|
if (!workspace) return toolError(`Workspace ${workspaceId} not found.`);
|
||||||
|
|
||||||
|
return toolText(
|
||||||
|
JSON.stringify({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
name: workspace.name,
|
||||||
|
member_count: memberCount,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tools.push(getWorkspaceInfo);
|
||||||
|
|
||||||
|
const getUsers = defineTool({
|
||||||
|
name: 'get_users',
|
||||||
|
title: 'Get Workspace Users',
|
||||||
|
description:
|
||||||
|
'List workspace members (id, name, email, avatar and role) so callers can mention or reference them.',
|
||||||
|
parser: z.object({
|
||||||
|
query: z.string().trim().min(1).max(255).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
|
}),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Filter by name or email.' },
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
||||||
|
offset: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
execute: async ({ query, limit, offset }, options) => {
|
||||||
|
await this.ac
|
||||||
|
.user(userId)
|
||||||
|
.workspace(workspaceId)
|
||||||
|
.assert('Workspace.Users.Read');
|
||||||
|
|
||||||
|
const aborted = abortIfNeeded(options.signal);
|
||||||
|
if (aborted) return aborted;
|
||||||
|
|
||||||
|
const pagination: PaginationInput = {
|
||||||
|
first: Math.min(limit ?? 20, 100),
|
||||||
|
offset: offset ?? 0,
|
||||||
|
};
|
||||||
|
const rows = query
|
||||||
|
? await this.models.workspaceUser.search(
|
||||||
|
workspaceId,
|
||||||
|
query,
|
||||||
|
pagination
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
await this.models.workspaceUser.paginate(workspaceId, pagination)
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
return toolText(
|
||||||
|
JSON.stringify({
|
||||||
|
users: rows.flatMap(row =>
|
||||||
|
row.status === 'Accepted' && row.user
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: row.user.id,
|
||||||
|
name: row.user.name,
|
||||||
|
email: row.user.email,
|
||||||
|
avatar_url: row.user.avatarUrl,
|
||||||
|
role: WorkspaceRole[row.type] ?? 'Unknown',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getComments = defineTool({
|
||||||
|
name: 'get_comments',
|
||||||
|
title: 'Get Document Comments',
|
||||||
|
description:
|
||||||
|
'List comments (with replies) on a document, newest first. Requires the credential owner to be able to read comments on that document.',
|
||||||
|
parser: z.object({
|
||||||
|
docId: z.string(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
}),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
docId: { type: 'string', description: 'The document ID.' },
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
required: ['docId'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
execute: async ({ docId, limit }, options) => {
|
||||||
|
await this.ac.user(userId).doc({ workspaceId, docId }).can('Doc.Read');
|
||||||
|
|
||||||
|
const aborted = abortIfNeeded(options.signal);
|
||||||
|
if (aborted) return aborted;
|
||||||
|
|
||||||
|
const comments = await this.models.comment.list(workspaceId, docId, {
|
||||||
|
take: limit ?? 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toolText(JSON.stringify({ comments }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createComment = defineTool({
|
||||||
|
name: 'create_comment',
|
||||||
|
title: 'Create Document Comment',
|
||||||
|
description:
|
||||||
|
'Add a plain-text comment on a document as the credential owner. Returns the created comment ID.',
|
||||||
|
parser: z.object({
|
||||||
|
docId: z.string(),
|
||||||
|
content: z.string().trim().min(1).max(5000),
|
||||||
|
}),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
docId: { type: 'string', description: 'The document ID.' },
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Plain text comment body.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['docId', 'content'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
execute: async ({ docId, content }, options) => {
|
||||||
|
const accessible = await this.ac
|
||||||
|
.user(userId)
|
||||||
|
.doc({ workspaceId, docId })
|
||||||
|
.can('Doc.Comments.Create');
|
||||||
|
if (!accessible) return toolError(`Doc with id ${docId} not found.`);
|
||||||
|
|
||||||
|
const aborted = abortIfNeeded(options.signal);
|
||||||
|
if (aborted) return aborted;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comment = await this.models.comment.create({
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
userId,
|
||||||
|
content: {
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: content }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return toolText(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
comment_id: comment.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return toolError(
|
||||||
|
`Failed to create comment: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accessMode === McpAccessMode.READ_WRITE) {
|
||||||
|
tools.push(createComment);
|
||||||
|
}
|
||||||
|
tools.push(getUsers, getComments);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: `AFFiNE MCP Server for Workspace ${workspaceId}`,
|
name: `AFFiNE MCP Server for Workspace ${workspaceId}`,
|
||||||
version: '1.0.1',
|
version: '1.1.0',
|
||||||
tools,
|
tools,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
@@ -109,7 +108,7 @@ export class McpCredentialResolver {
|
|||||||
|
|
||||||
@Query(() => Boolean)
|
@Query(() => Boolean)
|
||||||
mcpCredentialReadWriteAvailable() {
|
mcpCredentialReadWriteAvailable() {
|
||||||
return env.dev || env.namespaces.canary;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => RevealedMcpCredentialType)
|
@Mutation(() => RevealedMcpCredentialType)
|
||||||
@@ -117,13 +116,6 @@ export class McpCredentialResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('input') input: CreateMcpCredentialInput
|
@Args('input') input: CreateMcpCredentialInput
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
input.accessMode === McpAccessMode.READ_WRITE &&
|
|
||||||
!env.dev &&
|
|
||||||
!env.namespaces.canary
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('MCP write tools are not available');
|
|
||||||
}
|
|
||||||
await this.ac
|
await this.ac
|
||||||
.user(user.id)
|
.user(user.id)
|
||||||
.workspace(input.workspaceId)
|
.workspace(input.workspaceId)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
NativeEmbeddingService,
|
NativeEmbeddingService,
|
||||||
} from './embedding';
|
} from './embedding';
|
||||||
import { CopilotEmbeddingRealtimeProvider } from './embedding/realtime';
|
import { CopilotEmbeddingRealtimeProvider } from './embedding/realtime';
|
||||||
|
import { ManagedCopilotProfileResolver } from './managed-profile.resolver';
|
||||||
import { WorkspaceMcpProvider } from './mcp/provider';
|
import { WorkspaceMcpProvider } from './mcp/provider';
|
||||||
import { PromptService } from './prompt';
|
import { PromptService } from './prompt';
|
||||||
import { CopilotResolver, UserCopilotResolver } from './resolver';
|
import { CopilotResolver, UserCopilotResolver } from './resolver';
|
||||||
@@ -112,6 +113,7 @@ export const COPILOT_RESOLVER_PROVIDERS = [
|
|||||||
CopilotResolver,
|
CopilotResolver,
|
||||||
UserCopilotResolver,
|
UserCopilotResolver,
|
||||||
WorkspaceByokResolver,
|
WorkspaceByokResolver,
|
||||||
|
ManagedCopilotProfileResolver,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const COPILOT_JOB_PROVIDERS = [CopilotCronJobs];
|
export const COPILOT_JOB_PROVIDERS = [CopilotCronJobs];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export enum CopilotProviderType {
|
|||||||
Gemini = 'gemini',
|
Gemini = 'gemini',
|
||||||
GeminiVertex = 'geminiVertex',
|
GeminiVertex = 'geminiVertex',
|
||||||
OpenAI = 'openai',
|
OpenAI = 'openai',
|
||||||
|
OpenAICompatible = 'openaiCompatible',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CopilotProviderSchema = z.object({
|
export const CopilotProviderSchema = z.object({
|
||||||
|
|||||||
@@ -1448,6 +1448,33 @@ input ManageUserInput {
|
|||||||
name: String
|
name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedProfileModelProbeCheckType {
|
||||||
|
operation: ByokProbeOperation!
|
||||||
|
status: ManagedProfileProbeStatusType!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedProfileModelProbeType {
|
||||||
|
checks: [ManagedProfileModelProbeCheckType!]!
|
||||||
|
modelId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ManagedProfileProbeCheckInput {
|
||||||
|
modelId: String!
|
||||||
|
operation: ByokProbeOperation!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedProfileProbeResultType {
|
||||||
|
connection: ManagedProfileProbeStatusType!
|
||||||
|
definitionFingerprint: String!
|
||||||
|
models: [ManagedProfileModelProbeType!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedProfileProbeStatusType {
|
||||||
|
errorKind: String
|
||||||
|
kind: ByokProbeStatusKind!
|
||||||
|
testedAt: DateTime
|
||||||
|
}
|
||||||
|
|
||||||
enum McpAccessMode {
|
enum McpAccessMode {
|
||||||
READ_ONLY
|
READ_ONLY
|
||||||
READ_WRITE
|
READ_WRITE
|
||||||
@@ -1631,6 +1658,11 @@ type Mutation {
|
|||||||
"""mention user in a doc"""
|
"""mention user in a doc"""
|
||||||
mentionUser(input: MentionInput!): ID!
|
mentionUser(input: MentionInput!): ID!
|
||||||
previewLicense(license: Upload!): AdminLicensePreview!
|
previewLicense(license: Upload!): AdminLicensePreview!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test a server-managed copilot provider profile by dispatching real probe requests.
|
||||||
|
"""
|
||||||
|
probeManagedCopilotProfile(checks: [ManagedProfileProbeCheckInput!]!, profileId: ID!): ManagedProfileProbeResultType!
|
||||||
probeWorkspaceByokDraft(input: ProbeWorkspaceByokDraftInput!): WorkspaceByokProbeResultType!
|
probeWorkspaceByokDraft(input: ProbeWorkspaceByokDraftInput!): WorkspaceByokProbeResultType!
|
||||||
probeWorkspaceByokProfile(input: ProbeWorkspaceByokProfileInput!): WorkspaceByokProbeResultType!
|
probeWorkspaceByokProfile(input: ProbeWorkspaceByokProfileInput!): WorkspaceByokProbeResultType!
|
||||||
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!
|
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
mutation probeManagedCopilotProfile($profileId: ID!, $checks: [ManagedProfileProbeCheckInput!]!) {
|
||||||
|
probeManagedCopilotProfile(profileId: $profileId, checks: $checks) {
|
||||||
|
connection {
|
||||||
|
kind
|
||||||
|
testedAt
|
||||||
|
errorKind
|
||||||
|
}
|
||||||
|
models {
|
||||||
|
modelId
|
||||||
|
checks {
|
||||||
|
operation
|
||||||
|
status {
|
||||||
|
kind
|
||||||
|
testedAt
|
||||||
|
errorKind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -512,6 +512,31 @@ export const listUsersQuery = {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const probeManagedCopilotProfileMutation = {
|
||||||
|
id: 'probeManagedCopilotProfileMutation' as const,
|
||||||
|
op: 'probeManagedCopilotProfile',
|
||||||
|
query: `mutation probeManagedCopilotProfile($profileId: ID!, $checks: [ManagedProfileProbeCheckInput!]!) {
|
||||||
|
probeManagedCopilotProfile(profileId: $profileId, checks: $checks) {
|
||||||
|
connection {
|
||||||
|
kind
|
||||||
|
testedAt
|
||||||
|
errorKind
|
||||||
|
}
|
||||||
|
models {
|
||||||
|
modelId
|
||||||
|
checks {
|
||||||
|
operation
|
||||||
|
status {
|
||||||
|
kind
|
||||||
|
testedAt
|
||||||
|
errorKind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const rotateAuthSigningKeyMutation = {
|
export const rotateAuthSigningKeyMutation = {
|
||||||
id: 'rotateAuthSigningKeyMutation' as const,
|
id: 'rotateAuthSigningKeyMutation' as const,
|
||||||
op: 'rotateAuthSigningKey',
|
op: 'rotateAuthSigningKey',
|
||||||
|
|||||||
@@ -1641,6 +1641,36 @@ export interface ManageUserInput {
|
|||||||
name?: InputMaybe<Scalars['String']['input']>;
|
name?: InputMaybe<Scalars['String']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagedProfileModelProbeCheckType {
|
||||||
|
__typename?: 'ManagedProfileModelProbeCheckType';
|
||||||
|
operation: ByokProbeOperation;
|
||||||
|
status: ManagedProfileProbeStatusType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedProfileModelProbeType {
|
||||||
|
__typename?: 'ManagedProfileModelProbeType';
|
||||||
|
checks: Array<ManagedProfileModelProbeCheckType>;
|
||||||
|
modelId: Scalars['String']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedProfileProbeCheckInput {
|
||||||
|
modelId: Scalars['String']['input'];
|
||||||
|
operation: ByokProbeOperation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedProfileProbeResultType {
|
||||||
|
__typename?: 'ManagedProfileProbeResultType';
|
||||||
|
connection: ManagedProfileProbeStatusType;
|
||||||
|
models: Array<ManagedProfileModelProbeType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedProfileProbeStatusType {
|
||||||
|
__typename?: 'ManagedProfileProbeStatusType';
|
||||||
|
errorKind: Maybe<Scalars['String']['output']>;
|
||||||
|
kind: ByokProbeStatusKind;
|
||||||
|
testedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
}
|
||||||
|
|
||||||
export enum McpAccessMode {
|
export enum McpAccessMode {
|
||||||
READ_ONLY = 'READ_ONLY',
|
READ_ONLY = 'READ_ONLY',
|
||||||
READ_WRITE = 'READ_WRITE',
|
READ_WRITE = 'READ_WRITE',
|
||||||
@@ -1813,6 +1843,8 @@ export interface Mutation {
|
|||||||
/** mention user in a doc */
|
/** mention user in a doc */
|
||||||
mentionUser: Scalars['ID']['output'];
|
mentionUser: Scalars['ID']['output'];
|
||||||
previewLicense: AdminLicensePreview;
|
previewLicense: AdminLicensePreview;
|
||||||
|
/** Test a server-managed copilot provider profile by dispatching real probe requests. */
|
||||||
|
probeManagedCopilotProfile: ManagedProfileProbeResultType;
|
||||||
probeWorkspaceByokDraft: WorkspaceByokProbeResultType;
|
probeWorkspaceByokDraft: WorkspaceByokProbeResultType;
|
||||||
probeWorkspaceByokProfile: WorkspaceByokProbeResultType;
|
probeWorkspaceByokProfile: WorkspaceByokProbeResultType;
|
||||||
publishDoc: DocType;
|
publishDoc: DocType;
|
||||||
@@ -2115,6 +2147,11 @@ export interface MutationPreviewLicenseArgs {
|
|||||||
license: Scalars['Upload']['input'];
|
license: Scalars['Upload']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationProbeManagedCopilotProfileArgs {
|
||||||
|
checks: Array<ManagedProfileProbeCheckInput>;
|
||||||
|
profileId: Scalars['ID']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationProbeWorkspaceByokDraftArgs {
|
export interface MutationProbeWorkspaceByokDraftArgs {
|
||||||
input: ProbeWorkspaceByokDraftInput;
|
input: ProbeWorkspaceByokDraftInput;
|
||||||
}
|
}
|
||||||
@@ -4278,6 +4315,38 @@ export type ListUsersQuery = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProbeManagedCopilotProfileMutationVariables = Exact<{
|
||||||
|
profileId: Scalars['ID']['input'];
|
||||||
|
checks: Array<ManagedProfileProbeCheckInput> | ManagedProfileProbeCheckInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ProbeManagedCopilotProfileMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
probeManagedCopilotProfile: {
|
||||||
|
__typename?: 'ManagedProfileProbeResultType';
|
||||||
|
connection: {
|
||||||
|
__typename?: 'ManagedProfileProbeStatusType';
|
||||||
|
kind: ByokProbeStatusKind;
|
||||||
|
testedAt: string | null;
|
||||||
|
errorKind: string | null;
|
||||||
|
};
|
||||||
|
models: Array<{
|
||||||
|
__typename?: 'ManagedProfileModelProbeType';
|
||||||
|
modelId: string;
|
||||||
|
checks: Array<{
|
||||||
|
__typename?: 'ManagedProfileModelProbeCheckType';
|
||||||
|
operation: ByokProbeOperation;
|
||||||
|
status: {
|
||||||
|
__typename?: 'ManagedProfileProbeStatusType';
|
||||||
|
kind: ByokProbeStatusKind;
|
||||||
|
testedAt: string | null;
|
||||||
|
errorKind: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type RotateAuthSigningKeyMutationVariables = Exact<{
|
export type RotateAuthSigningKeyMutationVariables = Exact<{
|
||||||
expectedActiveKeyId: Scalars['String']['input'];
|
expectedActiveKeyId: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
@@ -8125,6 +8194,11 @@ export type Mutations =
|
|||||||
variables: ImportUsersMutationVariables;
|
variables: ImportUsersMutationVariables;
|
||||||
response: ImportUsersMutation;
|
response: ImportUsersMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'probeManagedCopilotProfileMutation';
|
||||||
|
variables: ProbeManagedCopilotProfileMutationVariables;
|
||||||
|
response: ProbeManagedCopilotProfileMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'rotateAuthSigningKeyMutation';
|
name: 'rotateAuthSigningKeyMutation';
|
||||||
variables: RotateAuthSigningKeyMutationVariables;
|
variables: RotateAuthSigningKeyMutationVariables;
|
||||||
|
|||||||
@@ -389,6 +389,10 @@
|
|||||||
"byok.allowPrivateEndpoint": {
|
"byok.allowPrivateEndpoint": {
|
||||||
"type": "Boolean",
|
"type": "Boolean",
|
||||||
"desc": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network."
|
"desc": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network."
|
||||||
|
},
|
||||||
|
"providers.profiles": {
|
||||||
|
"type": "Array",
|
||||||
|
"desc": "The profile list for copilot providers."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexer": {
|
"indexer": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ComponentType } from 'react';
|
|||||||
import CONFIG_DESCRIPTORS from '../../config.json';
|
import CONFIG_DESCRIPTORS from '../../config.json';
|
||||||
import type { ConfigInputProps } from './config-input-row';
|
import type { ConfigInputProps } from './config-input-row';
|
||||||
import { AuthSigningKeys } from './operations/auth-signing-keys';
|
import { AuthSigningKeys } from './operations/auth-signing-keys';
|
||||||
|
import { ProbeManagedProfiles } from './operations/probe-managed-profiles';
|
||||||
import { SendTestEmail } from './operations/send-test-email';
|
import { SendTestEmail } from './operations/send-test-email';
|
||||||
export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum';
|
export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum';
|
||||||
|
|
||||||
@@ -163,6 +164,18 @@ export const KNOWN_CONFIG_GROUPS = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as ConfigGroup<'copilot'>,
|
} as ConfigGroup<'copilot'>,
|
||||||
|
{
|
||||||
|
name: 'AI Providers',
|
||||||
|
module: 'copilot',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'providers.profiles',
|
||||||
|
desc: 'Server-managed AI provider profiles. Edit as JSON, then use Test to verify connectivity. Example: [{"id":"local-vllm","provider":"openaiCompatible","name":"Local vLLM","enabled":true,"config":{"baseURL":"http://localhost:8000/v1","apiKey":"none","dialect":"chat_completions"},"models":["qwen2.5-7b-instruct"]}].',
|
||||||
|
type: 'JSON',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
operations: [ProbeManagedProfiles],
|
||||||
|
} as ConfigGroup<'copilot'>,
|
||||||
{
|
{
|
||||||
name: 'Indexer',
|
name: 'Indexer',
|
||||||
module: 'indexer',
|
module: 'indexer',
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const triggerMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@affine/admin/use-mutation', () => ({
|
||||||
|
useMutation: () => ({ trigger: triggerMock, isMutating: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/component', () => ({
|
||||||
|
notify: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ProbeManagedProfiles } from './probe-managed-profiles';
|
||||||
|
|
||||||
|
describe('ProbeManagedProfiles', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
triggerMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders nothing when no managed profiles configured', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProbeManagedProfiles appConfig={{ copilot: {} }} />
|
||||||
|
);
|
||||||
|
expect(container.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probes each enabled profile model with declared capabilities', async () => {
|
||||||
|
triggerMock.mockResolvedValue({
|
||||||
|
probeManagedCopilotProfile: {
|
||||||
|
connection: { kind: 'verified', errorKind: null },
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelId: 'qwen',
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
operation: 'chat',
|
||||||
|
status: { kind: 'verified', errorKind: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProbeManagedProfiles
|
||||||
|
appConfig={{
|
||||||
|
copilot: {
|
||||||
|
'providers.profiles': [
|
||||||
|
{
|
||||||
|
id: 'local-vllm',
|
||||||
|
name: 'Local vLLM',
|
||||||
|
enabled: true,
|
||||||
|
models: [{ id: 'qwen', capabilities: ['chat', 'tools'] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disabled-profile',
|
||||||
|
enabled: false,
|
||||||
|
models: ['m'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Test Local vLLM' });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /disabled-profile/ })
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(triggerMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(triggerMock).toHaveBeenCalledWith({
|
||||||
|
profileId: 'local-vllm',
|
||||||
|
checks: [
|
||||||
|
{ modelId: 'qwen', operation: 'chat' },
|
||||||
|
{ modelId: 'qwen', operation: 'tool_calling' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Connection:/)).toBeDefined();
|
||||||
|
expect(screen.getByText(/qwen: chat verified/)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults to chat check when capabilities are undeclared', async () => {
|
||||||
|
triggerMock.mockResolvedValue({
|
||||||
|
probeManagedCopilotProfile: {
|
||||||
|
connection: { kind: 'failed', errorKind: 'http_401' },
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelId: 'm1',
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
operation: 'chat',
|
||||||
|
status: { kind: 'failed', errorKind: 'http_401' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProbeManagedProfiles
|
||||||
|
appConfig={{
|
||||||
|
copilot: {
|
||||||
|
'providers.profiles': [{ id: 'p1', models: ['m1'] }],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Test p1' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(triggerMock).toHaveBeenCalledWith({
|
||||||
|
profileId: 'p1',
|
||||||
|
checks: [{ modelId: 'm1', operation: 'chat' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/failed \(http_401\)/)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
|
import { useMutation } from '@affine/admin/use-mutation';
|
||||||
|
import { notify } from '@affine/component';
|
||||||
|
import {
|
||||||
|
type ByokProbeOperation,
|
||||||
|
type ByokProbeStatusKind,
|
||||||
|
probeManagedCopilotProfileMutation,
|
||||||
|
} from '@affine/graphql';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { AppConfig } from '../config';
|
||||||
|
|
||||||
|
type ProfileLike = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
models?: Array<string | { id: string; capabilities?: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAPABILITY_OPERATION: Record<string, ByokProbeOperation> = {
|
||||||
|
chat: 'chat',
|
||||||
|
structured: 'structured',
|
||||||
|
tools: 'tool_calling',
|
||||||
|
vision: 'vision',
|
||||||
|
embedding: 'embedding',
|
||||||
|
rerank: 'rerank',
|
||||||
|
image: 'image',
|
||||||
|
};
|
||||||
|
|
||||||
|
function checksForModel(
|
||||||
|
model: NonNullable<ProfileLike['models']>[number]
|
||||||
|
): Array<{
|
||||||
|
modelId: string;
|
||||||
|
operation: ByokProbeOperation;
|
||||||
|
}> {
|
||||||
|
const modelId = typeof model === 'string' ? model : model.id;
|
||||||
|
const capabilities =
|
||||||
|
typeof model === 'string' ? [] : (model.capabilities ?? []);
|
||||||
|
if (capabilities.length === 0) {
|
||||||
|
// Undeclared capabilities: probe basic chat as the default smoke test.
|
||||||
|
return [{ modelId, operation: 'chat' }];
|
||||||
|
}
|
||||||
|
return capabilities
|
||||||
|
.map(
|
||||||
|
(capability: string): ByokProbeOperation | undefined =>
|
||||||
|
CAPABILITY_OPERATION[capability]
|
||||||
|
)
|
||||||
|
.filter((operation): operation is ByokProbeOperation => Boolean(operation))
|
||||||
|
.map(operation => ({ modelId, operation }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<ByokProbeStatusKind, string> = {
|
||||||
|
verified: 'verified',
|
||||||
|
failed: 'failed',
|
||||||
|
not_tested: 'not tested',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProbeManagedProfiles({ appConfig }: { appConfig: AppConfig }) {
|
||||||
|
const { trigger, isMutating } = useMutation({
|
||||||
|
mutation: probeManagedCopilotProfileMutation,
|
||||||
|
});
|
||||||
|
const [results, setResults] = useState<{
|
||||||
|
profileId: string;
|
||||||
|
connection: { kind: string; errorKind?: string | null };
|
||||||
|
models: Array<{
|
||||||
|
modelId: string;
|
||||||
|
checks: Array<{
|
||||||
|
operation: string;
|
||||||
|
status: { kind: string; errorKind?: string | null };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const profiles = useMemo(() => {
|
||||||
|
const raw = appConfig?.copilot?.['providers.profiles'];
|
||||||
|
return Array.isArray(raw) ? (raw as ProfileLike[]) : [];
|
||||||
|
}, [appConfig]);
|
||||||
|
|
||||||
|
const onTest = useCallback(
|
||||||
|
async (profile: ProfileLike) => {
|
||||||
|
if (!profile.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
const checks = (profile.models ?? []).flatMap(checksForModel);
|
||||||
|
try {
|
||||||
|
const result = await trigger({
|
||||||
|
profileId: profile.id,
|
||||||
|
checks: checks.length > 0 ? checks : [],
|
||||||
|
});
|
||||||
|
setResults({
|
||||||
|
profileId: profile.id,
|
||||||
|
...result.probeManagedCopilotProfile,
|
||||||
|
});
|
||||||
|
notify.success({
|
||||||
|
title: `Tested ${profile.name || profile.id}`,
|
||||||
|
message: `Connection ${result.probeManagedCopilotProfile.connection.kind}.`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[trigger]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="text-sm font-semibold leading-6 text-foreground">
|
||||||
|
Test provider connectivity
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{profiles.map(profile =>
|
||||||
|
profile.id && profile.enabled !== false ? (
|
||||||
|
<div key={profile.id} className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={isMutating}
|
||||||
|
onClick={() => void onTest(profile)}
|
||||||
|
>
|
||||||
|
Test {profile.name || profile.id}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||||
|
{results && (
|
||||||
|
<div className="rounded-md border border-border/60 p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
Connection:{' '}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
results.connection.kind === 'verified'
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-destructive'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{results.connection.kind}
|
||||||
|
</span>
|
||||||
|
{results.connection.errorKind
|
||||||
|
? ` (${results.connection.errorKind})`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
{results.models.map(model => (
|
||||||
|
<div key={model.modelId} className="mt-1 pl-3">
|
||||||
|
{model.modelId}:{' '}
|
||||||
|
{model.checks
|
||||||
|
.map(
|
||||||
|
check =>
|
||||||
|
`${check.operation} ${STATUS_LABEL[check.status.kind] ?? check.status.kind}${
|
||||||
|
check.status.errorKind
|
||||||
|
? ` (${check.status.errorKind})`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
scripts/deploy-gitea.sh
Executable file
191
scripts/deploy-gitea.sh
Executable file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Gitea Docker Deploy Script
|
||||||
|
# Build multi-platform Docker image and push to Gitea container registry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Load config from file if exists ---
|
||||||
|
CONFIG_FILE=".gitea-deploy"
|
||||||
|
if [[ -f "$CONFIG_FILE" ]]; then
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
# Priority: 1) environment variables, 2) .gitea-deploy file, 3) auto-detect
|
||||||
|
DOMAIN="${GITEA_DOMAIN:-$DOMAIN}"
|
||||||
|
REPO_PATH="${GITEA_REPO:-$REPO_PATH}"
|
||||||
|
PLATFORMS="${DOCKER_PLATFORMS:-${PLATFORMS:-linux/amd64,linux/arm64}}"
|
||||||
|
DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
|
||||||
|
|
||||||
|
# --- Output colors ---
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# --- Auto-detect from git remote ---
|
||||||
|
detect_gitea_info() {
|
||||||
|
local remote_url
|
||||||
|
local remote_name="${GIT_REMOTE:-}"
|
||||||
|
|
||||||
|
# Priority: 1) GIT_REMOTE env, 2) remote "gitea", 3) remote "origin"
|
||||||
|
if [[ -n "$remote_name" ]]; then
|
||||||
|
remote_url=$(git remote get-url "$remote_name" 2>/dev/null || echo "")
|
||||||
|
elif git remote get-url gitea &>/dev/null; then
|
||||||
|
remote_name="gitea"
|
||||||
|
remote_url=$(git remote get-url gitea)
|
||||||
|
log_info "Using remote 'gitea'"
|
||||||
|
else
|
||||||
|
remote_name="origin"
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$remote_url" ]]; then
|
||||||
|
log_error "Git remote 'origin' not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse URL: support multiple formats
|
||||||
|
# SSH short: git@gitea.luulam.dev:luulam/9router.git
|
||||||
|
# SSH full: ssh://git@192.168.1.10:2222/luulam/9router.git
|
||||||
|
# HTTPS: https://gitea.luulam.dev/luulam/9router.git
|
||||||
|
|
||||||
|
local detected_domain=""
|
||||||
|
local detected_repo=""
|
||||||
|
|
||||||
|
if [[ "$remote_url" =~ ^ssh:// ]]; then
|
||||||
|
# SSH full format: ssh://git@host:port/path.git
|
||||||
|
detected_domain=$(echo "$remote_url" | sed 's|ssh://[^@]*@\([^:/]*\).*|\1|')
|
||||||
|
detected_repo=$(echo "$remote_url" | sed 's|ssh://[^/]*/\(.*\)\.git|\1|' | sed 's|\.git$||')
|
||||||
|
elif [[ "$remote_url" =~ ^git@ ]]; then
|
||||||
|
# SSH short format: git@host:path.git
|
||||||
|
detected_domain=$(echo "$remote_url" | sed 's/git@\([^:]*\):.*/\1/')
|
||||||
|
detected_repo=$(echo "$remote_url" | sed 's/git@[^:]*:\(.*\)\.git/\1/' | sed 's/\.git$//')
|
||||||
|
elif [[ "$remote_url" =~ ^https?:// ]]; then
|
||||||
|
# HTTPS format
|
||||||
|
detected_domain=$(echo "$remote_url" | sed 's|https\?://\([^/]*\)/.*|\1|')
|
||||||
|
detected_repo=$(echo "$remote_url" | sed 's|https\?://[^/]*/\(.*\)\.git|\1|' | sed 's/\.git$//')
|
||||||
|
else
|
||||||
|
log_error "Unrecognized git remote format: $remote_url"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use detected values if not configured
|
||||||
|
REPO_PATH="${REPO_PATH:-$detected_repo}"
|
||||||
|
|
||||||
|
# Check if domain is IP -> need to configure DOMAIN
|
||||||
|
if [[ -z "$DOMAIN" ]]; then
|
||||||
|
if [[ "$detected_domain" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
log_error "Git remote points to IP ($detected_domain), not domain."
|
||||||
|
log_error "Please create .gitea-deploy file with:"
|
||||||
|
echo ""
|
||||||
|
echo " DOMAIN=\"gitea.luulam.dev\""
|
||||||
|
echo " REPO_PATH=\"$detected_repo\""
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DOMAIN="$detected_domain"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Check prerequisites ---
|
||||||
|
check_prerequisites() {
|
||||||
|
log_info "Checking prerequisites..."
|
||||||
|
|
||||||
|
# Dockerfile
|
||||||
|
if [[ ! -f "$DOCKERFILE" ]]; then
|
||||||
|
log_error "$DOCKERFILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docker daemon
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
log_error "Docker daemon not running. Please start Docker Desktop."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docker buildx
|
||||||
|
if ! docker buildx version > /dev/null 2>&1; then
|
||||||
|
log_error "Docker Buildx not installed."
|
||||||
|
log_info "Install with: docker buildx install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if builder supports multi-platform
|
||||||
|
local builder_platforms
|
||||||
|
builder_platforms=$(docker buildx inspect --bootstrap 2>/dev/null | grep -i "platforms" || echo "")
|
||||||
|
if [[ -z "$builder_platforms" ]]; then
|
||||||
|
log_warn "Creating new multi-platform builder..."
|
||||||
|
docker buildx create --use --name multiarch-builder --driver docker-container --bootstrap
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "All prerequisites ready!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Build and Push ---
|
||||||
|
build_and_push() {
|
||||||
|
local git_rev
|
||||||
|
git_rev=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
local image_base="$DOMAIN/$REPO_PATH"
|
||||||
|
local tag_rev="$image_base:$git_rev"
|
||||||
|
local tag_latest="$image_base:latest"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_info "=========================================="
|
||||||
|
log_info "Build Docker Image"
|
||||||
|
log_info "=========================================="
|
||||||
|
log_info "Registry: $DOMAIN"
|
||||||
|
log_info "Repository: $REPO_PATH"
|
||||||
|
log_info "Platforms: $PLATFORMS"
|
||||||
|
log_info "Tags: $git_rev, latest"
|
||||||
|
log_info "Dockerfile: $DOCKERFILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build and push
|
||||||
|
docker buildx build \
|
||||||
|
--platform "$PLATFORMS" \
|
||||||
|
--tag "$tag_rev" \
|
||||||
|
--tag "$tag_latest" \
|
||||||
|
--file "$DOCKERFILE" \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
if [[ $? -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
log_info "=========================================="
|
||||||
|
log_info "Deploy successful!"
|
||||||
|
log_info "=========================================="
|
||||||
|
log_info "Image: $tag_rev"
|
||||||
|
log_info "Image: $tag_latest"
|
||||||
|
echo ""
|
||||||
|
log_info "Pull image:"
|
||||||
|
echo " docker pull $tag_latest"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
log_error "Build failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
main() {
|
||||||
|
echo ""
|
||||||
|
log_info "Gitea Docker Deploy"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detect info if not configured
|
||||||
|
if [[ -z "$DOMAIN" ]] || [[ -z "$REPO_PATH" ]]; then
|
||||||
|
detect_gitea_info
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_prerequisites
|
||||||
|
build_and_push
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user