mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-05 19:16:07 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c2c975cb | |||
| d6f01adb8a | |||
| 1f415459d9 | |||
| 098a15bd14 | |||
| 5461628eec | |||
| c48c345b13 | |||
| f83a163b15 | |||
| d40348add3 |
@@ -1,59 +0,0 @@
|
|||||||
name: Build and deploy production
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [deploy/prod]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: langbot-production
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
|
||||||
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
|
||||||
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build exact Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
${{ env.CORE_IMAGE }}:deploy-prod
|
|
||||||
cache-from: type=gha,scope=core-prod
|
|
||||||
cache-to: type=gha,mode=max,scope=core-prod
|
|
||||||
- name: Checkout production Cloud adapter
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: langbot-app/langbot-space
|
|
||||||
ref: ${{ env.SPACE_REF }}
|
|
||||||
token: ${{ secrets.CLA_PAT }}
|
|
||||||
path: .space
|
|
||||||
- name: Build exact Cloud Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .space
|
|
||||||
file: .space/Dockerfile.cloud
|
|
||||||
push: true
|
|
||||||
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
tags: |
|
|
||||||
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
${{ env.CLOUD_IMAGE }}:deploy-prod
|
|
||||||
cache-from: type=gha,scope=cloud-core-prod
|
|
||||||
cache-to: type=gha,mode=max,scope=cloud-core-prod
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
cd /opt/langbot-cloud-prod
|
|
||||||
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
|
|
||||||
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
|
|
||||||
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
|
|
||||||
|
|
||||||
rendered_compose=$(docker compose config)
|
|
||||||
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
|
|
||||||
exit 4
|
|
||||||
}
|
|
||||||
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
|
|
||||||
exit 5
|
|
||||||
}
|
|
||||||
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud telemetry ingest token must be configured' >&2
|
|
||||||
exit 6
|
|
||||||
}
|
|
||||||
|
|
||||||
update_env() {
|
|
||||||
local key=$1 value=$2
|
|
||||||
python3 - "$key" "$value" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = Path('.env')
|
|
||||||
key, value = sys.argv[1:]
|
|
||||||
lines = path.read_text().splitlines()
|
|
||||||
updated = False
|
|
||||||
for index, line in enumerate(lines):
|
|
||||||
if line.startswith(f'{key}='):
|
|
||||||
lines[index] = f'{key}={value}'
|
|
||||||
updated = True
|
|
||||||
break
|
|
||||||
if not updated:
|
|
||||||
lines.append(f'{key}={value}')
|
|
||||||
temporary = Path('.env.tmp')
|
|
||||||
temporary.write_text('\n'.join(lines) + '\n')
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
temporary.replace(path)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
update_env LANGBOT_IMAGE_TAG "$TAG"
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
|
|
||||||
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if docker compose pull postgres redis migrate plugin-runtime core; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$attempt" -eq 5 ]; then
|
|
||||||
echo "docker compose pull failed after $attempt attempts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
delay=$((attempt * 10))
|
|
||||||
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
|
|
||||||
sleep "$delay"
|
|
||||||
done
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
|
|
||||||
|
|
||||||
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
|
|
||||||
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
|
|
||||||
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
|
|
||||||
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
|
|
||||||
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
|
|
||||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
|
|
||||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
|
|
||||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
|
|
||||||
GRANT USAGE ON SCHEMA public TO langbot_runtime;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
docker compose --profile tools run --rm migrate
|
|
||||||
|
|
||||||
docker compose up -d --remove-orphans plugin-runtime core
|
|
||||||
for _ in $(seq 1 90); do
|
|
||||||
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
|
|
||||||
docker compose ps
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
docker compose logs --tail=200 core plugin-runtime >&2
|
|
||||||
exit 1
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
container_name: langbot-cloud-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: langbot
|
|
||||||
POSTGRES_USER: langbot_operator
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7.4-alpine
|
|
||||||
container_name: langbot-cloud-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
environment:
|
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
|
||||||
profiles: [tools]
|
|
||||||
command: [uv, run, langbot, migrate, --cloud]
|
|
||||||
environment: &core-env
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
|
|
||||||
SYSTEM__EDITION: cloud
|
|
||||||
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
|
|
||||||
SYSTEM__JWT__SECRET: ${JWT_SECRET}
|
|
||||||
SYSTEM__LIMITATION__MAX_BOTS: "2"
|
|
||||||
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
|
|
||||||
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
|
|
||||||
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
|
|
||||||
API__WEBHOOK_PREFIX: https://cloud.langbot.app
|
|
||||||
API__WEBUI_URL: https://cloud.langbot.app
|
|
||||||
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
|
|
||||||
DATABASE__USE: postgresql
|
|
||||||
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
|
|
||||||
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
|
|
||||||
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
|
|
||||||
VDB__USE: pgvector
|
|
||||||
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
|
|
||||||
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
|
|
||||||
PLUGIN__ENABLE: "true"
|
|
||||||
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
|
|
||||||
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
|
|
||||||
PLUGIN__WORKER__MAX_CPUS: "0.25"
|
|
||||||
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
|
|
||||||
PLUGIN__WORKER__MAX_PIDS: "128"
|
|
||||||
PLUGIN__WORKER__MAX_WORKERS: "16"
|
|
||||||
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
|
|
||||||
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
|
|
||||||
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
|
|
||||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
|
||||||
# Cloud v2 currently grants no managed Box capability. Keep the shared
|
|
||||||
# runtime deployed but disable Core integration until a hard-quota-capable
|
|
||||||
# backend can satisfy the fail-closed Cloud readiness contract.
|
|
||||||
BOX__ENABLED: "false"
|
|
||||||
BOX__BACKEND: nsjail
|
|
||||||
BOX__RUNTIME__ENDPOINT: ws://box:5410
|
|
||||||
BOX__ADMISSION__REQUIRED: "true"
|
|
||||||
BOX__ADMISSION__LOGICAL_SESSION_ID: global
|
|
||||||
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
|
|
||||||
BOX__ADMISSION__MAX_SESSIONS: "1"
|
|
||||||
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
|
|
||||||
BOX__ADMISSION__CPUS: "0.25"
|
|
||||||
BOX__ADMISSION__MEMORY_MB: "256"
|
|
||||||
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
|
|
||||||
BOX__LOCAL__HOST_ROOT: /app/data/box
|
|
||||||
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
|
|
||||||
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
|
|
||||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
|
||||||
MCP__STDIO__ENABLED: "false"
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
|
||||||
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
|
|
||||||
SPACE__URL: https://space.langbot.app
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
plugin-runtime:
|
|
||||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-plugin-runtime
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
|
|
||||||
environment:
|
|
||||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
|
||||||
volumes:
|
|
||||||
- plugin-data:/app/data
|
|
||||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
||||||
cgroup: host
|
|
||||||
privileged: true
|
|
||||||
expose: ["5400"]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
box:
|
|
||||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-box
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
|
|
||||||
environment:
|
|
||||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
|
||||||
LANGBOT_BOX_ROOT: /app/data/box
|
|
||||||
volumes:
|
|
||||||
- box-data:/app/data/box
|
|
||||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
||||||
cgroup: host
|
|
||||||
privileged: true
|
|
||||||
expose: ["5410"]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
core:
|
|
||||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-core
|
|
||||||
restart: unless-stopped
|
|
||||||
environment: *core-env
|
|
||||||
volumes:
|
|
||||||
- core-data:/app/data
|
|
||||||
- box-data:/app/data/box
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
redis: {condition: service_healthy}
|
|
||||||
plugin-runtime: {condition: service_started}
|
|
||||||
box: {condition: service_started}
|
|
||||||
expose: ["5300"]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 30s
|
|
||||||
networks: [internal, shared-network]
|
|
||||||
|
|
||||||
networks:
|
|
||||||
internal:
|
|
||||||
shared-network:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
redis-data:
|
|
||||||
plugin-data:
|
|
||||||
box-data:
|
|
||||||
core-data:
|
|
||||||
@@ -14,8 +14,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or set the same value on
|
# Shared with the langbot service and sent only as a WebSocket handshake
|
||||||
# both to protect the control WebSocket. Generate with: openssl rand -hex 32
|
# header. Generate with: openssl rand -hex 32
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Process-wide admission for every asyncio.to_thread() call.
|
# Process-wide admission for every asyncio.to_thread() call.
|
||||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||||
@@ -77,7 +77,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
# Must match langbot_plugin_runtime. Empty/missing values make the
|
||||||
|
# external control channel fail closed.
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Must match the value supplied to langbot_box. The token is sent only
|
# Must match the value supplied to langbot_box. The token is sent only
|
||||||
# in WebSocket handshake headers, never in URLs or action payloads.
|
# in WebSocket handshake headers, never in URLs or action payloads.
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
# 模型思考控制设计方案
|
||||||
|
|
||||||
|
> 日期:2026-07-31
|
||||||
|
> 状态:Phase 1 已审核并实现
|
||||||
|
> 范围:LangBot 主仓库的模型配置、LiteLLM 请求层、Local Agent、Web 管理面板、监控与测试
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
建议为 LangBot 增加一套与厂商参数解耦的“思考策略”模型,并明确区分三个概念:
|
||||||
|
|
||||||
|
1. **思考能力**:模型是否支持思考,以及支持开关、档位还是 token 预算。
|
||||||
|
2. **思考策略**:一次请求选择厂商默认、关闭、开启或指定思考档位。
|
||||||
|
3. **思考展示**:是否把模型返回的思考内容展示给最终用户。
|
||||||
|
|
||||||
|
现有 `remove-think` 只属于第 3 类。它会过滤输出,但不会阻止模型思考,也不会降低思考 token、费用或延迟。新能力不应复用或改写这个字段。
|
||||||
|
|
||||||
|
推荐实现原则:
|
||||||
|
|
||||||
|
- 默认值为 `provider_default`,不向上游增加任何新参数,现有模型行为完全不变。
|
||||||
|
- 用户显式选择的策略必须被准确执行;无法准确执行时返回明确错误,不静默降级。
|
||||||
|
- LangBot 内部只保存统一策略,Provider 请求层负责翻译成各厂商参数。
|
||||||
|
- `extra_args` 保留为高级逃生口,但不能成为主 UI 的思考配置方式。
|
||||||
|
- 模型页只管理并展示能力;可写策略归属于 Local Agent 流水线,同一模型可在不同业务中使用不同思考量。
|
||||||
|
- 原始 reasoning 数据与展示文本分开保存,保证多轮对话、工具调用和签名字段不丢失。
|
||||||
|
|
||||||
|
## 2. 调研结论
|
||||||
|
|
||||||
|
### 2.1 可验证资料
|
||||||
|
|
||||||
|
本次结论基于以下可验证来源:
|
||||||
|
|
||||||
|
- OpenAI 官方 Reasoning Guide:`reasoning.effort` 的可选值由模型决定,可包括 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`;低档位偏向低延迟和低 token,高档位偏向质量。
|
||||||
|
- https://developers.openai.com/api/docs/guides/reasoning#reasoning-effort
|
||||||
|
- LangBot 锁定的 LiteLLM `1.88.1` 实现。`uv.lock` 已锁定该版本,本地缓存中的适配代码可以确认 LangBot 实际依赖所支持的翻译行为。
|
||||||
|
- LangBot 当前实现:模型级 `extra_args` 会在 `LiteLLMRequester._build_completion_args()` 中直接合并到 `acompletion()` 参数。
|
||||||
|
|
||||||
|
Anthropic、Google 和 LiteLLM 的官方文档域名在本次环境中被浏览器策略禁止访问,因此下表中这些厂商的结论以 LiteLLM `1.88.1` 实际适配代码为准。实施前应再用对应厂商官方文档做一次参数范围核验,尤其是模型代际和允许值。
|
||||||
|
|
||||||
|
### 2.2 厂商差异矩阵
|
||||||
|
|
||||||
|
| Provider / 生态 | 可控制能力 | LiteLLM 1.88.1 统一入口 | 关键限制 | 建议支持级别 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| OpenAI | 思考档位,部分模型支持 `none` | `reasoning_effort` | 每个模型支持的档位不同,不能把 `none` 当成通用能力 | 首批完整支持 |
|
||||||
|
| Anthropic | 旧模型使用 extended thinking + token budget;新模型可用 adaptive thinking + effort | `reasoning_effort` 或 `thinking` | `none` 表示不发送 thinking;新旧模型的映射不同 | 首批完整支持 |
|
||||||
|
| Gemini | 2.x 主要映射为 `thinkingBudget`;3.x 主要映射为 `thinkingLevel` | `reasoning_effort` 或 `thinking` | Gemini 3 的 `none` 可能只能降到最低档,不能保证真正关闭 | 首批支持,但严格限制关闭语义 |
|
||||||
|
| DeepSeek | 开启/关闭;当前适配不支持预算档位 | `thinking={type: enabled}`;非 `none` effort 会映射成开启 | 多轮思考模式要求回传 `reasoning_content` | 首批开关支持 |
|
||||||
|
| xAI | 思考档位 | `reasoning_effort` | 仅 reasoning-capable 模型接受 | 首批完整支持 |
|
||||||
|
| Ollama | `think` 布尔值;部分模型接受 low/medium/high | `reasoning_effort` | 非 gpt-oss 模型的档位可能退化为布尔开关 | 首批支持,按模型能力裁剪 UI |
|
||||||
|
| OpenRouter | 聚合多厂商的 reasoning 参数 | `reasoning_effort`、`thinking` | 实际能力由路由后的模型决定 | 首批支持,能力未知时要求测试 |
|
||||||
|
| Volcengine / Doubao | `thinking.type` 支持 enabled/disabled/auto | LiteLLM `volcengine` 适配器支持 `thinking` | LangBot 当前 manifest 使用 `openai`,不会进入该适配器 | 第二批,先修正路由并回归 |
|
||||||
|
| Bailian / Qwen | 厂商兼容接口有独立思考开关/预算 | LiteLLM `dashscope` 适配器目前未提供统一 reasoning 映射 | LangBot 当前 manifest 使用 `openai`,只能通过高级参数透传 | 第二批,实施前核对官方字段 |
|
||||||
|
| 其他 OpenAI-compatible 网关 | 取决于网关 | 尝试标准 `reasoning_effort` | 不能仅凭模型名推断完整能力 | 保守支持,默认不自动开启 |
|
||||||
|
|
||||||
|
### 2.3 对 LangBot 的直接含义
|
||||||
|
|
||||||
|
不能把这个功能实现成单一 `enable_thinking: bool`,原因如下:
|
||||||
|
|
||||||
|
- 有的模型只有开关,有的模型只有档位,有的模型允许精确 token 预算。
|
||||||
|
- 有的模型本身始终推理,只能降低思考量,无法真正关闭。
|
||||||
|
- 同一个通用档位在不同厂商会映射成不同的实际预算。
|
||||||
|
- 聚合网关和自定义 OpenAI-compatible 服务无法可靠地通过模型名识别能力。
|
||||||
|
- “不展示思考内容”不等于“关闭思考”。
|
||||||
|
|
||||||
|
## 3. 当前项目现状
|
||||||
|
|
||||||
|
### 3.1 已有能力
|
||||||
|
|
||||||
|
- `LLMModel.extra_args` 是 JSON 字段,Web 端已有通用高级参数编辑器。
|
||||||
|
- `LiteLLMRequester` 会按“模型级 `extra_args`,再调用级 `extra_args`”的顺序合并参数。
|
||||||
|
- LiteLLM 已统一处理多个 Provider 的 `reasoning_effort`、`thinking` 和返回的 `reasoning_content`。
|
||||||
|
- `LocalAgentRunner` 的非流式、流式、工具调用和 fallback 路径都经过 `RuntimeProvider.invoke_llm*()`。
|
||||||
|
- `remove-think` 已能控制 `<think>` 或独立 reasoning 内容是否进入展示文本。
|
||||||
|
- Gemini 工具调用所需的 `provider_specific_fields` / thought signature 已有保留逻辑和单元测试。
|
||||||
|
|
||||||
|
### 3.2 现有缺口
|
||||||
|
|
||||||
|
- 管理员只能手写 `extra_args`,没有统一语义、能力提示和校验。
|
||||||
|
- `remove-think` 名称容易被误解为关闭模型思考。
|
||||||
|
- 模型扫描只识别 `vision` 和 `func_call`,没有 reasoning 能力。
|
||||||
|
- 当前返回处理会把 `reasoning_content` 拼进 `<think>` 文本后删除原字段,可能损失多轮思考所需的结构化数据。
|
||||||
|
- DeepSeek 思考模式需要在后续轮次回传 `reasoning_content`,当前链路不能保证完整保留。
|
||||||
|
- Pipeline 只能选择模型,不能针对业务覆盖模型的思考策略。
|
||||||
|
- 监控只记录总输入/输出 token,没有单独展示 reasoning token。
|
||||||
|
- 部分 Provider manifest 仍声明为通用 `openai`,导致 LiteLLM 的厂商专用翻译器不会生效。
|
||||||
|
|
||||||
|
### 3.3 预计改动地图
|
||||||
|
|
||||||
|
| 层 | 主要文件 | 责任 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 持久化 | `src/langbot/pkg/entity/persistence/model.py`、`src/langbot/pkg/persistence/alembic/versions/` | 新增 `reasoning_config` JSON 列和 Alembic 迁移 |
|
||||||
|
| 模型服务 | `src/langbot/pkg/api/http/service/model.py` | CRUD 校验、冲突检测、测试模型时使用统一策略 |
|
||||||
|
| HTTP 控制器 | `src/langbot/pkg/api/http/controller/groups/provider/models.py` | 继续复用现有模型路由,不新增平行 API |
|
||||||
|
| 模型管理 | `src/langbot/pkg/provider/modelmgr/modelmgr.py` | 临时模型、数据库模型与扫描结果加载新字段 |
|
||||||
|
| 请求抽象 | `src/langbot/pkg/provider/modelmgr/requester.py` | 定义能力查询和 reasoning 参数构建接口 |
|
||||||
|
| LiteLLM 适配 | `src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py` | 能力识别、策略翻译、参数合并、reasoning 返回保留 |
|
||||||
|
| Provider manifest | `src/langbot/pkg/provider/modelmgr/requesters/*.yaml` | 必要时修正 Provider 路由;相关变更放到独立阶段 |
|
||||||
|
| Agent 调用 | `src/langbot/pkg/provider/runners/localagent.py` | 所有非流式、流式、工具调用、fallback 路径传递统一策略 |
|
||||||
|
| Pipeline 元数据 | `src/langbot/templates/metadata/pipeline/ai.yaml` | 第二阶段加入 Pipeline 级覆盖 |
|
||||||
|
| 输出配置 | `src/langbot/templates/metadata/pipeline/output.yaml` | 保留键名,澄清 `remove-think` 只控制展示 |
|
||||||
|
| Web 类型/API | `web/src/app/infra/entities/api/index.ts`、`web/src/app/infra/http/BackendClient.ts` | 增加配置与能力响应类型 |
|
||||||
|
| 模型 UI | `web/src/app/home/components/models-dialog/` | 能力标记、策略控件、校验、模型测试 |
|
||||||
|
| i18n | `web/src/i18n/locales/` | 至少补齐英文、简体中文及项目已有覆盖语言 |
|
||||||
|
| 测试 | `tests/unit_tests/provider/`、`web/tests/` | 翻译、服务、流式 round-trip、前端状态测试 |
|
||||||
|
|
||||||
|
Phase 1 不修改 `langbot-plugin-sdk` 的公共实体或运行时协议。现有 `provider_message.Message.provider_specific_fields` 已可承载 Provider 原始 reasoning 数据;只有后续要把 reasoning 升级为跨插件公开实体时,才需要跨仓库 SDK 变更。
|
||||||
|
|
||||||
|
## 4. 领域模型
|
||||||
|
|
||||||
|
### 4.1 统一策略
|
||||||
|
|
||||||
|
新增 `ReasoningConfig`,保存于 LLM 模型,Pipeline 可提供同结构覆盖。产品层只暴露一个离散档位:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"level": "provider_default"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段定义:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `level` | `provider_default \| disabled \| enabled \| minimal \| low \| medium \| high \| xhigh \| max` | 同时表达开关和思考强度 |
|
||||||
|
|
||||||
|
校验规则:
|
||||||
|
|
||||||
|
- `provider_default`:不发送任何 reasoning 参数,保持厂商和模型默认行为。
|
||||||
|
- `disabled`:明确关闭;仅当模型可真正关闭时允许保存/运行。
|
||||||
|
- `enabled`:明确开启,但由 Provider 决定具体强度,适用于只有开关的模型。
|
||||||
|
- `minimal` 到 `max`:明确开启,并指定强度;仅允许选择模型实际支持的档位。
|
||||||
|
- 厂商的 `auto` 统一映射为 `provider_default`,不再增加一个重复状态。
|
||||||
|
- 精确 token 预算不进入主数据结构。少数需要预算的场景继续通过高级参数配置,并由模型测试接口校验。
|
||||||
|
|
||||||
|
### 4.2 能力描述
|
||||||
|
|
||||||
|
沿用现有 `LLMModel.abilities`,新增 `reasoning` 能力标记。同时由后端在 API 返回中计算只读的 `reasoning_capabilities`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"supported": true,
|
||||||
|
"controls": ["toggle", "effort"],
|
||||||
|
"efforts": ["none", "low", "medium", "high"],
|
||||||
|
"can_disable": true,
|
||||||
|
"source": "litellm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- `abilities` 仍是用户可编辑的粗粒度能力,符合现有 `vision`、`func_call` 模式。
|
||||||
|
- `reasoning_capabilities` 不持久化,优先从 LiteLLM 模型元数据计算,避免模型升级后数据库残留过期能力。
|
||||||
|
- 无法识别的自定义模型返回 `supported: null`、`source: unknown`,不猜测。
|
||||||
|
- 用户可手动添加 `reasoning` ability,但未知能力模型必须先通过“测试模型”验证显式策略。
|
||||||
|
- UI 只展示后端声明可用的控件;未知模型保留 Provider Default 和高级参数入口。
|
||||||
|
|
||||||
|
### 4.3 持久化
|
||||||
|
|
||||||
|
在 `llm_models` 表新增 JSON 列:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reasoning_config JSON NOT NULL DEFAULT {"level":"provider_default"}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 Alembic 新迁移,不修改冻结的 legacy migration。
|
||||||
|
|
||||||
|
该列作为已实现版本的兼容字段保留;新的模型页不再提供写入口,Local Agent 请求以流水线中按模型 UUID 保存的策略为准。
|
||||||
|
|
||||||
|
不建议把内部策略塞进 `extra_args`,原因是当前 `extra_args` 会原样发送给 LiteLLM;使用保留键会让内部元数据泄漏到上游,并使高级参数与产品配置难以区分。
|
||||||
|
|
||||||
|
## 5. 配置优先级与请求流程
|
||||||
|
|
||||||
|
### 5.1 优先级
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pipeline 当前候选模型策略
|
||||||
|
↓ 缺少配置时固定为 provider_default
|
||||||
|
Provider / 模型默认行为
|
||||||
|
```
|
||||||
|
|
||||||
|
请求参数合并顺序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
基础参数
|
||||||
|
-> 模型 extra_args
|
||||||
|
-> 调用级 extra_args
|
||||||
|
-> 统一 reasoning 策略翻译结果(最后应用)
|
||||||
|
```
|
||||||
|
|
||||||
|
统一策略最后应用,可以确保流水线行为不受模型页历史设置影响。为了避免用户困惑,保存和测试时要检测 `extra_args` 中的冲突字段;当 `level != provider_default` 时,发现以下字段应直接报错:
|
||||||
|
|
||||||
|
- `reasoning_effort`
|
||||||
|
- `thinking`
|
||||||
|
- `reasoning`
|
||||||
|
- `extra_body` 内已知的 `thinking`、`enable_thinking`、`thinking_budget` 等字段
|
||||||
|
|
||||||
|
当 `level == provider_default` 时继续允许这些高级参数,保证旧配置兼容。
|
||||||
|
|
||||||
|
### 5.2 翻译层
|
||||||
|
|
||||||
|
在 `pkg/provider/modelmgr/` 内新增独立的 reasoning 规范化模块,职责是:
|
||||||
|
|
||||||
|
1. 读取当前流水线候选模型的请求级策略。
|
||||||
|
2. 查询 `ProviderAPIRequester.get_reasoning_capabilities(model)`。
|
||||||
|
3. 严格校验策略是否可以准确执行。
|
||||||
|
4. 生成 LiteLLM 参数,不直接发 HTTP。
|
||||||
|
5. 返回可观测的“最终生效策略”供日志和测试使用。
|
||||||
|
|
||||||
|
建议接口:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ProviderAPIRequester:
|
||||||
|
def get_reasoning_capabilities(self, model: RuntimeLLMModel) -> ReasoningCapabilities: ...
|
||||||
|
|
||||||
|
def build_reasoning_args(
|
||||||
|
self,
|
||||||
|
model: RuntimeLLMModel,
|
||||||
|
config: ReasoningConfig,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
LiteLLMRequester 默认优先生成统一参数:
|
||||||
|
|
||||||
|
- 强度档位:`reasoning_effort=<level>`
|
||||||
|
- 仅开启:`thinking={"type":"enabled"}` 或 Provider 等价参数
|
||||||
|
- 关闭:优先 `reasoning_effort="none"`
|
||||||
|
- 高级参数中的精确预算:`thinking={"type":"enabled","budget_tokens":N}`
|
||||||
|
|
||||||
|
Provider 特例只放在 requester 翻译层,不进入 Pipeline 或平台适配器。
|
||||||
|
|
||||||
|
### 5.3 Provider 特例
|
||||||
|
|
||||||
|
- **Gemini 3**:如果 LiteLLM 能力表不能确认真正关闭,`disabled` 必须报“不支持关闭,可选择 Provider Default 或最低档”,不能把 `none` 静默映射成 low/minimal。
|
||||||
|
- **DeepSeek**:所有非 `none` 档位最终都只是开启。能力 API 只返回 `toggle`,UI 不显示档位;多轮必须保存并回传 `reasoning_content`。
|
||||||
|
- **Ollama**:仅对明确支持等级的模型展示 effort;其他模型只展示开关。
|
||||||
|
- **OpenRouter**:以路由后的模型能力为准。模型未知时允许 Provider Default,显式策略必须通过测试接口。
|
||||||
|
- **Volcengine**:使用 `thinking.type=enabled/disabled/auto`。应先让该 requester 进入 LiteLLM `volcengine` 适配器,或增加等价的明确翻译,不能依赖模型名。
|
||||||
|
- **Bailian/Qwen**:作为第二批 Provider 专用翻译。实施前核对官方字段、模型范围、预算上下限和流式返回结构,不凭经验写接口。
|
||||||
|
|
||||||
|
## 6. 返回数据与思考展示
|
||||||
|
|
||||||
|
### 6.1 保留原始 reasoning
|
||||||
|
|
||||||
|
当前 `LiteLLMRequester` 会读取 `reasoning_content`,将其拼接成 `<think>` 文本,再删除原字段。建议改为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
上游 reasoning_content
|
||||||
|
├─ 原样保存在 Message.provider_specific_fields.reasoning_content
|
||||||
|
└─ 根据 remove-think 决定是否渲染为 <think>...</think>
|
||||||
|
```
|
||||||
|
|
||||||
|
流式路径需要在 accumulator 中分别累计 `content` 与 `reasoning_content`,最终消息必须携带结构化 reasoning。不能只依赖已经渲染的 `<think>` 文本反向解析。
|
||||||
|
|
||||||
|
这样可以同时满足:
|
||||||
|
|
||||||
|
- `remove-think=true` 时用户看不到思考内容,但多轮协议仍能回传必要数据。
|
||||||
|
- `remove-think=false` 时保持当前用户体验。
|
||||||
|
- DeepSeek 多轮 thinking 不丢上下文。
|
||||||
|
- Gemini thought signature、Anthropic thinking block 等 Provider 字段可以继续按结构化方式 round-trip。
|
||||||
|
|
||||||
|
### 6.2 现有字段处理
|
||||||
|
|
||||||
|
保留数据库和 Pipeline 配置键 `remove-think`,避免破坏兼容。Web 文案改为更准确的:
|
||||||
|
|
||||||
|
- 中文:`向用户展示思考过程`
|
||||||
|
- 英文:`Show reasoning process`
|
||||||
|
|
||||||
|
UI 使用正向开关,保存时转换回 `remove-think = !showReasoning`。文案必须强调它只影响展示,不影响模型是否思考、token 或费用。
|
||||||
|
|
||||||
|
## 7. Web 管理面板
|
||||||
|
|
||||||
|
### 7.1 模型编辑
|
||||||
|
|
||||||
|
模型页只承担能力管理和只读展示:
|
||||||
|
|
||||||
|
1. `Reasoning` ability 复选框与 Vision、Function Calling 并列,供无法自动识别的自定义模型手动声明能力。
|
||||||
|
2. 模型卡片使用简短图标或 badge 标识 reasoning 能力。
|
||||||
|
3. 模型页不提供可写思考挡位,避免模型默认值与流水线策略形成两个控制源。
|
||||||
|
|
||||||
|
### 7.2 Local Agent 流水线策略
|
||||||
|
|
||||||
|
在 Local Agent 的主模型和每一个 fallback 模型下分别显示紧凑离散滑杆:
|
||||||
|
|
||||||
|
1. `Provider 默认` 始终为首个选项;选择它时不向上游增加任何思考参数。
|
||||||
|
2. 完整档位顺序为:`Provider 默认 / 关闭 / 开启 / 最低 / 低 / 中 / 高 / 极高 / 最大`。
|
||||||
|
3. 前端只渲染后端为该模型返回的可用档位;仅开关模型显示 `Provider 默认 / 关闭 / 开启`。
|
||||||
|
4. 模型不能真正关闭时不提供 `关闭`;能力未知时只显示不可调的 `Provider 默认`。
|
||||||
|
5. 主模型和 fallback 分别保存策略,切换候选模型时不会把一个模型的挡位错误应用到另一个模型。
|
||||||
|
6. Dify、Coze、Langflow、n8n 等外部 Runner 不显示该控件,因为 LangBot 不直接发起其内部模型请求。
|
||||||
|
|
||||||
|
流水线配置保持旧格式兼容,并在模型选择对象中增加按 UUID 保存的映射:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": {
|
||||||
|
"primary": "primary-model-uuid",
|
||||||
|
"fallbacks": ["fallback-model-uuid"],
|
||||||
|
"reasoning": {
|
||||||
|
"primary-model-uuid": "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`provider_default` 不写入映射;缺少 `reasoning` 的旧流水线天然等价于全部使用 Provider 默认。
|
||||||
|
|
||||||
|
滑杆交互要求:轨道使用现有主色和中性灰,不使用渐变;当前档位同时显示文字;支持键盘方向键和正确的 ARIA value text;窄屏下不溢出。
|
||||||
|
|
||||||
|
### 7.3 i18n
|
||||||
|
|
||||||
|
新增文案至少覆盖 `en_US`、`zh_Hans`;`ja_JP` 在模型面板现有同类字段已覆盖时同步补齐。不要把厂商参数名直接作为用户文案。
|
||||||
|
|
||||||
|
## 8. API、MCP 与 Skill
|
||||||
|
|
||||||
|
### 8.1 HTTP API
|
||||||
|
|
||||||
|
模型 CRUD 增加:
|
||||||
|
|
||||||
|
- 请求字段:`reasoning_config`
|
||||||
|
- 响应字段:`reasoning_config`
|
||||||
|
- 只读字段:`reasoning_capabilities`
|
||||||
|
|
||||||
|
模型测试接口必须使用与真实请求完全相同的规范化和翻译逻辑,并在失败时返回可操作错误,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Model gemini-3-... cannot disable reasoning.
|
||||||
|
Supported controls: effort=[low, medium, high].
|
||||||
|
```
|
||||||
|
|
||||||
|
可选增加只读调试信息,仅在测试接口返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"effective_reasoning": {
|
||||||
|
"level": "low",
|
||||||
|
"translated_keys": ["reasoning_effort"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不得返回 API key、完整请求正文或原始思考内容。
|
||||||
|
|
||||||
|
### 8.2 MCP 与技能
|
||||||
|
|
||||||
|
当前 MCP 仅列出模型 Provider,没有完整模型 CRUD 工具。如果本次不新增 agent-accessible HTTP 操作,则无需强行新增 MCP 工具。
|
||||||
|
|
||||||
|
如果后续让 Agent 修改模型思考策略,则必须同一提交更新:
|
||||||
|
|
||||||
|
- `src/langbot/pkg/api/mcp/server.py`
|
||||||
|
- 对应的 `skills/` 文档
|
||||||
|
- 参数 schema 和安全说明
|
||||||
|
|
||||||
|
## 9. 监控与可观测性
|
||||||
|
|
||||||
|
控制思考量后,管理员需要判断质量、延迟和成本是否值得。建议第二阶段增加:
|
||||||
|
|
||||||
|
- `reasoning_tokens`:从 `completion_tokens_details.reasoning_tokens` 或 Provider 等价字段提取。
|
||||||
|
- `effective_reasoning_level`:记录规范化后的生效档位,不记录原始思考内容。
|
||||||
|
- 模型监控页展示输入 token、可见输出 token、reasoning token、总延迟。
|
||||||
|
- Provider 不返回细分 token 时显示未知,不推算。
|
||||||
|
|
||||||
|
安全要求:日志、监控、debug API 默认都不得记录 reasoning 原文。思考内容可能包含敏感信息或系统提示,不应因为新增配置而扩大持久化范围。
|
||||||
|
|
||||||
|
## 10. 兼容与迁移
|
||||||
|
|
||||||
|
### 10.1 数据迁移
|
||||||
|
|
||||||
|
- 所有现有 LLM 记录迁移为 `{"level":"provider_default"}`。
|
||||||
|
- 不自动解析或迁移现有 `extra_args` 中的 reasoning 参数,避免误判嵌套结构和 Provider 语义。
|
||||||
|
- UI 检测到旧 `extra_args` reasoning 字段时显示“由高级参数控制”,统一策略保持 Provider Default。
|
||||||
|
- 用户主动改成统一策略时,要求先移除冲突高级参数。
|
||||||
|
|
||||||
|
### 10.2 运行时兼容
|
||||||
|
|
||||||
|
- `provider_default` 不产生任何新增请求参数。
|
||||||
|
- 不改变现有 `remove-think` 的存储键和默认值。
|
||||||
|
- 不改变已有 Provider 的 `litellm_provider`,除非该 Provider 在专项回归后单独切换。
|
||||||
|
- `drop_params` 不能用于掩盖显式 reasoning 配置错误;显式策略被丢弃应视为失败。
|
||||||
|
- 自托管和 toB 环境中的自定义兼容接口保持可用,未知能力不阻止 Provider Default 请求。
|
||||||
|
|
||||||
|
## 11. 实施拆分
|
||||||
|
|
||||||
|
### Phase 1:统一基础设施与主流 Provider
|
||||||
|
|
||||||
|
- Alembic 增加 `llm_models.reasoning_config`。
|
||||||
|
- Backend 模型实体、CRUD、测试接口支持统一配置。
|
||||||
|
- LiteLLMRequester 增加能力查询、严格校验和参数翻译。
|
||||||
|
- 支持 OpenAI、Anthropic、Gemini、DeepSeek、xAI、Ollama、OpenRouter 的已验证 LiteLLM 路径。
|
||||||
|
- 修复结构化 reasoning 的非流式/流式保留。
|
||||||
|
- 模型面板增加 reasoning ability 与只读能力标识。
|
||||||
|
- Local Agent 主模型和每个 fallback 增加独立的请求级策略。
|
||||||
|
|
||||||
|
### Phase 2:国内 Provider
|
||||||
|
|
||||||
|
- 专项核对并支持 Volcengine/Doubao、Bailian/Qwen。
|
||||||
|
- 对相关 requester 的 `litellm_provider` 变更做独立回归,避免把 reasoning 功能和通用请求行为回归混在一起。
|
||||||
|
- 补齐扫描结果中的 reasoning capability。
|
||||||
|
|
||||||
|
### Phase 3:监控与评估
|
||||||
|
|
||||||
|
- 持久化 reasoning token 和生效策略。
|
||||||
|
- 监控页增加 reasoning 成本/延迟指标。
|
||||||
|
- 建立不同 effort 的离线质量、首 token 延迟、总耗时和 token 对比基线。
|
||||||
|
|
||||||
|
## 12. 测试方案
|
||||||
|
|
||||||
|
### 12.1 单元测试
|
||||||
|
|
||||||
|
- `ReasoningConfig` 所有合法/非法组合。
|
||||||
|
- `provider_default` 不产生任何新增参数。
|
||||||
|
- 显式配置覆盖模型/调用 `extra_args` 的顺序。
|
||||||
|
- reasoning 配置与高级参数冲突时拒绝。
|
||||||
|
- OpenAI 档位原样映射。
|
||||||
|
- Anthropic 档位映射,以及高级参数预算兼容。
|
||||||
|
- Gemini 2 budget、Gemini 3 level,以及不支持真正关闭时拒绝。
|
||||||
|
- DeepSeek 只显示/接受 toggle,非 `none` effort 不伪装成不同档位。
|
||||||
|
- Ollama 布尔与分级模型差异。
|
||||||
|
- Volcengine enabled/disabled/auto 翻译。
|
||||||
|
- 未知 Provider 只允许 Provider Default,或在显式测试后使用标准参数。
|
||||||
|
- 非流式 `reasoning_content` 保存到 `provider_specific_fields`。
|
||||||
|
- 流式 reasoning 分片累计后仍能 round-trip。
|
||||||
|
- Gemini thought signature 和工具调用现有测试不能回归。
|
||||||
|
|
||||||
|
### 12.2 服务与持久化测试
|
||||||
|
|
||||||
|
- 新建、读取、更新模型的 `reasoning_config`。
|
||||||
|
- Alembic 从当前 head 升级后默认值正确。
|
||||||
|
- 模型测试接口与真实 Local Agent 使用同一翻译函数。
|
||||||
|
- 旧模型、旧 `extra_args` 和 `remove-think` 行为不变。
|
||||||
|
|
||||||
|
### 12.3 前端测试
|
||||||
|
|
||||||
|
- 能力不同的模型显示正确控件。
|
||||||
|
- 离散滑杆只能停在后端返回的可用档位。
|
||||||
|
- 当前档位文字、键盘操作和 ARIA value text 正确。
|
||||||
|
- 仅开关模型、不可关闭模型、完整档位模型分别显示正确刻度。
|
||||||
|
- fallback 能力不兼容时阻止保存并给出明确提示。
|
||||||
|
- 中英文文案完整,移动端 Popover 不溢出。
|
||||||
|
|
||||||
|
### 12.4 Provider 冒烟测试
|
||||||
|
|
||||||
|
至少选取以下真实或可控 mock:
|
||||||
|
|
||||||
|
- 一个支持 `none` 的 OpenAI reasoning 模型。
|
||||||
|
- 一个不支持 `none` 的 reasoning 模型。
|
||||||
|
- 一个 Anthropic adaptive thinking 模型。
|
||||||
|
- 一个 Gemini 2.x 与一个 Gemini 3.x 模型。
|
||||||
|
- 一个 DeepSeek hybrid thinking 模型,执行两轮含工具调用对话。
|
||||||
|
- 一个 Ollama 本地 reasoning 模型。
|
||||||
|
- 一个 OpenAI-compatible 自定义网关,验证 Provider Default 完全不变。
|
||||||
|
|
||||||
|
每个模型比较 Provider Default、最低档、中档、高档或关闭,记录成功率、首 token 延迟、总耗时、总 token 和 reasoning token(若可用)。
|
||||||
|
|
||||||
|
## 13. 风险与控制
|
||||||
|
|
||||||
|
| 风险 | 影响 | 控制措施 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 将“最低思考”误当成“关闭” | 用户以为节省了成本,实际仍在推理 | `can_disable` 严格校验,不静默降级 |
|
||||||
|
| 模型能力表过期 | 新模型无法配置或旧模型报错 | 能力未知时保守;允许测试;升级 LiteLLM 时回归 |
|
||||||
|
| 高 effort 导致延迟/费用陡增 | 用户体验和预算风险 | 默认 Provider Default;UI 提示;后续监控 reasoning token |
|
||||||
|
| `extra_args` 与统一配置冲突 | 实际生效值不可预测 | 保存/测试时拒绝冲突;统一策略最后应用 |
|
||||||
|
| reasoning 原文进入日志 | 敏感信息泄露 | 不记录原文,只记录策略和 token |
|
||||||
|
| 多轮 reasoning 丢失 | 工具调用或后续轮次失败/降质 | 结构化保存并 round-trip;流式专项测试 |
|
||||||
|
| 修改 Provider 路由造成通用回归 | 非 reasoning 请求也受影响 | 国内 Provider 路由放第二阶段,独立提交和回归 |
|
||||||
|
|
||||||
|
## 14. 需要审核确认的决策
|
||||||
|
|
||||||
|
1. **是否同意三层分离**:能力、策略、展示互不替代,保留 `remove-think` 仅控制展示。
|
||||||
|
2. **是否同意严格语义**:显式关闭无法准确执行时直接报错,不自动降为最低思考。
|
||||||
|
3. **是否同意请求级配置**:流水线按模型 UUID 保存挡位,不把产品配置塞进 `extra_args`。
|
||||||
|
4. **是否同意 Runner 边界**:仅 Local Agent 展示控制项,外部 Runner 由其外部系统管理模型策略。
|
||||||
|
5. **是否同意保守默认**:所有现有模型迁移为 Provider Default,不自动开启、关闭或迁移旧高级参数。
|
||||||
|
6. **是否把结构化 reasoning 保留纳入第一阶段**:这是 DeepSeek 多轮和工具调用正确性的必要条件,建议必须纳入。
|
||||||
|
|
||||||
|
## 15. 推荐审核结果
|
||||||
|
|
||||||
|
建议按以上 6 项全部通过,并将 Phase 1 作为一个完整功能单元实施。不要只增加前端开关或只在 `extra_args` 中写 `reasoning_effort`;那样虽然改动小,但会继续混淆展示与推理、无法处理 Provider 差异,也无法保证多轮对话正确性。
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.6"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -71,7 +71,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
|
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ The `all` / `box` profile starts three services:
|
|||||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
||||||
never put it in `box.runtime.endpoint` or commit it to config.
|
never put it in `box.runtime.endpoint` or commit it to config.
|
||||||
|
|
||||||
A Compose deployment may optionally set
|
Every Compose deployment also needs one
|
||||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
|
||||||
`langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
|
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
|
||||||
defaults to leaving it unset on both sides. If enabled, generate one value with
|
before `docker compose up`; the external Plugin Runtime fails closed when the
|
||||||
`openssl rand -hex 32`; configuring only one side causes the control connection
|
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
|
||||||
to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
|
Secret shown in `docker/kubernetes.yaml`.
|
||||||
`docker/kubernetes.yaml`.
|
|
||||||
|
|
||||||
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
||||||
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Permission(enum.StrEnum):
|
|||||||
WORKSPACE_VIEW = 'workspace.view'
|
WORKSPACE_VIEW = 'workspace.view'
|
||||||
WORKSPACE_UPDATE = 'workspace.update'
|
WORKSPACE_UPDATE = 'workspace.update'
|
||||||
WORKSPACE_DELETE = 'workspace.delete'
|
WORKSPACE_DELETE = 'workspace.delete'
|
||||||
|
OWNER_TRANSFER = 'owner.transfer'
|
||||||
MEMBER_VIEW = 'member.view'
|
MEMBER_VIEW = 'member.view'
|
||||||
MEMBER_INVITE = 'member.invite'
|
MEMBER_INVITE = 'member.invite'
|
||||||
MEMBER_UPDATE_ROLE = 'member.update_role'
|
MEMBER_UPDATE_ROLE = 'member.update_role'
|
||||||
@@ -48,6 +49,7 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
|||||||
if permission
|
if permission
|
||||||
not in {
|
not in {
|
||||||
Permission.WORKSPACE_DELETE,
|
Permission.WORKSPACE_DELETE,
|
||||||
|
Permission.OWNER_TRANSFER,
|
||||||
Permission.BILLING_LINK_MANAGE,
|
Permission.BILLING_LINK_MANAGE,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class AuthType(enum.Enum):
|
|||||||
|
|
||||||
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
||||||
{
|
{
|
||||||
|
Permission.OWNER_TRANSFER.value,
|
||||||
Permission.MEMBER_VIEW.value,
|
Permission.MEMBER_VIEW.value,
|
||||||
Permission.MEMBER_INVITE.value,
|
Permission.MEMBER_INVITE.value,
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
Permission.MEMBER_UPDATE_ROLE.value,
|
||||||
|
|||||||
@@ -392,8 +392,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get plugin debug information including debug URL and key"""
|
"""Get plugin debug information including debug URL and key"""
|
||||||
execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
|
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||||
|
|
||||||
# Get debug URL from config
|
# Get debug URL from config
|
||||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||||
@@ -403,7 +403,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'debug_url': debug_url,
|
'debug_url': debug_url,
|
||||||
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
||||||
'expires_at': debug_info.get('expires_at', ''),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import quart
|
import quart
|
||||||
import argon2
|
import argon2
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
@@ -219,22 +218,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
|
|
||||||
workspace_created_ats: dict[str, int] = {}
|
|
||||||
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
|
|
||||||
binding = await self.ap.workspace_service.get_execution_binding()
|
|
||||||
workspace_uuids = [binding.workspace_uuid]
|
|
||||||
workspace_created_at = binding.workspace_created_at
|
|
||||||
if workspace_created_at is not None:
|
|
||||||
if workspace_created_at.tzinfo is None:
|
|
||||||
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
|
|
||||||
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
|
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
|
||||||
code,
|
|
||||||
workspace_uuids,
|
|
||||||
workspace_created_ats,
|
|
||||||
)
|
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
@@ -247,6 +231,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
access_token, refresh_token, expires_in
|
access_token, refresh_token, expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
|
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||||
if launch_workspace_uuid:
|
if launch_workspace_uuid:
|
||||||
try:
|
try:
|
||||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||||
@@ -300,25 +285,8 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
request_context.workspace_uuid,
|
request_context.workspace_uuid,
|
||||||
)
|
)
|
||||||
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
||||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
owner_space_bound = bool(owner and owner.space_account_uuid)
|
||||||
owner_has_local_space_credentials = bool(owner and owner.space_account_uuid)
|
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
|
||||||
# Cloud Accounts authenticate through LangBot Account, so every projected
|
|
||||||
# Workspace owner is already bound even when this Core has no local OAuth
|
|
||||||
# token row (model billing uses the owner's control-plane API key).
|
|
||||||
owner_space_bound = cloud_mode or owner_has_local_space_credentials
|
|
||||||
if cloud_mode:
|
|
||||||
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
|
||||||
credits = (
|
|
||||||
catalog_service.get_workspace_credits(access.workspace.uuid)
|
|
||||||
if catalog_service is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
credits = (
|
|
||||||
await self.ap.space_service.get_credits(owner.user)
|
|
||||||
if owner is not None and owner.space_account_uuid
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'credits': credits,
|
'credits': credits,
|
||||||
@@ -334,10 +302,8 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.success(data={'initialized': False})
|
return self.success(data={'initialized': False})
|
||||||
|
|
||||||
capabilities = await self.ap.user_service.get_login_capabilities()
|
capabilities = await self.ap.user_service.get_login_capabilities()
|
||||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||||
if cloud_mode:
|
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
|
||||||
return self.success(data={'initialized': True, **capabilities})
|
return self.success(data={'initialized': True, **capabilities})
|
||||||
|
|
||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
|||||||
@@ -30,14 +30,12 @@ def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
|
|||||||
def _membership_payload(
|
def _membership_payload(
|
||||||
membership: WorkspaceMembership,
|
membership: WorkspaceMembership,
|
||||||
*,
|
*,
|
||||||
display_name: str,
|
|
||||||
email: str,
|
email: str,
|
||||||
) -> dict[str, typing.Any]:
|
) -> dict[str, typing.Any]:
|
||||||
return {
|
return {
|
||||||
'uuid': membership.uuid,
|
'uuid': membership.uuid,
|
||||||
'workspace_uuid': membership.workspace_uuid,
|
'workspace_uuid': membership.workspace_uuid,
|
||||||
'account_uuid': membership.account_uuid,
|
'account_uuid': membership.account_uuid,
|
||||||
'display_name': display_name,
|
|
||||||
'email': email,
|
'email': email,
|
||||||
'role': membership.role,
|
'role': membership.role,
|
||||||
'status': membership.status,
|
'status': membership.status,
|
||||||
@@ -96,11 +94,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
workspaces.append(
|
workspaces.append(
|
||||||
{
|
{
|
||||||
'workspace': _workspace_payload(access.workspace),
|
'workspace': _workspace_payload(access.workspace),
|
||||||
'membership': _membership_payload(
|
'membership': _membership_payload(access.membership, email=account.user),
|
||||||
access.membership,
|
|
||||||
display_name=account.user,
|
|
||||||
email=account.normalized_email,
|
|
||||||
),
|
|
||||||
'permissions': sorted(permissions_for_role(access.membership.role)),
|
'permissions': sorted(permissions_for_role(access.membership.role)),
|
||||||
'placement_generation': access.execution.placement_generation,
|
'placement_generation': access.execution.placement_generation,
|
||||||
'plan_name': plan_name,
|
'plan_name': plan_name,
|
||||||
@@ -143,7 +137,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
'uuid': None,
|
'uuid': None,
|
||||||
'workspace_uuid': request_context.workspace_uuid,
|
'workspace_uuid': request_context.workspace_uuid,
|
||||||
'account_uuid': None,
|
'account_uuid': None,
|
||||||
'display_name': None,
|
|
||||||
'email': None,
|
'email': None,
|
||||||
'role': 'owner',
|
'role': 'owner',
|
||||||
'status': 'active',
|
'status': 'active',
|
||||||
@@ -161,11 +154,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'workspace': _workspace_payload(workspace),
|
'workspace': _workspace_payload(workspace),
|
||||||
'membership': _membership_payload(
|
'membership': _membership_payload(membership, email=account.user),
|
||||||
membership,
|
|
||||||
display_name=account.user,
|
|
||||||
email=account.normalized_email,
|
|
||||||
),
|
|
||||||
'permissions': sorted(request_context.workspace.permissions),
|
'permissions': sorted(request_context.workspace.permissions),
|
||||||
'placement_generation': request_context.placement_generation,
|
'placement_generation': request_context.placement_generation,
|
||||||
'plan_name': plan_name,
|
'plan_name': plan_name,
|
||||||
@@ -294,8 +283,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'member': _membership_payload(
|
'member': _membership_payload(
|
||||||
member,
|
member,
|
||||||
display_name=account.user if account is not None else '',
|
email=account.user if account is not None else '',
|
||||||
email=account.normalized_email if account is not None else '',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -314,11 +302,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
||||||
return _membership_payload(
|
return _membership_payload(view.membership, email=view.email)
|
||||||
view.membership,
|
|
||||||
display_name=view.display_name,
|
|
||||||
email=view.email,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('invitations', '/api/v1/invitations')
|
@group.group_class('invitations', '/api/v1/invitations')
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import uuid
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
|
|
||||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....provider.modelmgr import requester as model_requester
|
from ....provider.modelmgr import requester as model_requester
|
||||||
|
from ....provider.modelmgr import reasoning as model_reasoning
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
|
from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
@@ -55,6 +55,53 @@ def _redact_model_secrets(model_data: dict) -> dict:
|
|||||||
return redacted
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_llm_reasoning(model_data: dict) -> None:
|
||||||
|
model_data['reasoning_config'] = model_reasoning.validate_reasoning_config(
|
||||||
|
model_data.get('reasoning_config'),
|
||||||
|
model_data.get('abilities'),
|
||||||
|
model_data.get('extra_args'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_llm_reasoning_capability(
|
||||||
|
model_entity: persistence_model.LLMModel,
|
||||||
|
runtime_provider: model_requester.RuntimeProvider,
|
||||||
|
) -> None:
|
||||||
|
config = model_reasoning.normalize_reasoning_config(model_entity.reasoning_config)
|
||||||
|
if config['level'] == 'provider_default':
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime_model = model_requester.RuntimeLLMModel(
|
||||||
|
execution_context=runtime_provider.execution_context,
|
||||||
|
model_entity=model_entity,
|
||||||
|
provider=runtime_provider,
|
||||||
|
)
|
||||||
|
capabilities = runtime_provider.requester.get_reasoning_capabilities(runtime_model)
|
||||||
|
model_reasoning.validate_reasoning_capabilities(config, capabilities, model_entity.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_capabilities(ap: app.Application, model: persistence_model.LLMModel) -> dict:
|
||||||
|
model_mgr = getattr(ap, 'model_mgr', None)
|
||||||
|
runtime_models = getattr(model_mgr, 'llm_model_dict', {}) if model_mgr is not None else {}
|
||||||
|
for runtime_model in runtime_models.values():
|
||||||
|
if (
|
||||||
|
runtime_model.model_entity.uuid == model.uuid
|
||||||
|
and runtime_model.model_entity.workspace_uuid == model.workspace_uuid
|
||||||
|
):
|
||||||
|
return runtime_model.provider.requester.get_reasoning_capabilities(runtime_model)
|
||||||
|
return model_reasoning.default_reasoning_capabilities(
|
||||||
|
supported='reasoning' in (model.abilities or []),
|
||||||
|
source='manual' if 'reasoning' in (model.abilities or []) else 'unknown',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_llm_model(ap: app.Application, model: persistence_model.LLMModel) -> dict:
|
||||||
|
model_dict = ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||||
|
model_dict['reasoning_config'] = model_reasoning.normalize_reasoning_config(model_dict.get('reasoning_config'))
|
||||||
|
model_dict['reasoning_capabilities'] = _reasoning_capabilities(ap, model)
|
||||||
|
return model_dict
|
||||||
|
|
||||||
|
|
||||||
async def _validate_provider_supports(
|
async def _validate_provider_supports(
|
||||||
ap: app.Application,
|
ap: app.Application,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
@@ -114,23 +161,6 @@ async def _require_workspace_provider(
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
def _is_cloud_runtime(ap: app.Application) -> bool:
|
|
||||||
mode = getattr(ap.persistence_mgr, 'mode', None)
|
|
||||||
return getattr(mode, 'value', None) == 'cloud_runtime'
|
|
||||||
|
|
||||||
|
|
||||||
async def _assert_cloud_managed_provider_mutable(
|
|
||||||
ap: app.Application,
|
|
||||||
context: TenantContext,
|
|
||||||
provider_uuid: str,
|
|
||||||
) -> None:
|
|
||||||
if not _is_cloud_runtime(ap):
|
|
||||||
return
|
|
||||||
provider = await _require_workspace_provider(ap, context, provider_uuid)
|
|
||||||
if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER:
|
|
||||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_runtime_provider(
|
async def _require_runtime_provider(
|
||||||
ap: app.Application,
|
ap: app.Application,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
@@ -165,7 +195,7 @@ class LLMModelsService:
|
|||||||
|
|
||||||
models_list = []
|
models_list = []
|
||||||
for model in models:
|
for model in models:
|
||||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
model_dict = _serialize_llm_model(self.ap, model)
|
||||||
provider = providers.get(model.provider_uuid)
|
provider = providers.get(model.provider_uuid)
|
||||||
if provider:
|
if provider:
|
||||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||||
@@ -196,7 +226,7 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
models = result.all()
|
models = result.all()
|
||||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
|
serialized = [_serialize_llm_model(self.ap, model) for model in models]
|
||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_llm_model(
|
async def create_llm_model(
|
||||||
@@ -231,15 +261,18 @@ class LLMModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
||||||
|
_normalize_llm_reasoning(model_data)
|
||||||
|
|
||||||
|
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
|
model_entity = persistence_model.LLMModel(**model_data)
|
||||||
|
_validate_llm_reasoning_capability(model_entity, runtime_provider)
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||||
|
|
||||||
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
|
|
||||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||||
context,
|
context,
|
||||||
persistence_model.LLMModel(**model_data),
|
model_entity,
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
||||||
@@ -287,7 +320,7 @@ class LLMModelsService:
|
|||||||
if model is None:
|
if model is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
model_dict = _serialize_llm_model(self.ap, model)
|
||||||
|
|
||||||
# Get provider
|
# Get provider
|
||||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -310,17 +343,11 @@ class LLMModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_llm_model(
|
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_uuid: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Update an existing LLM model"""
|
"""Update an existing LLM model"""
|
||||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -346,9 +373,20 @@ class LLMModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
||||||
|
|
||||||
|
merged_model_data = {
|
||||||
|
key: value
|
||||||
|
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
|
||||||
|
if key not in {'provider', 'created_at', 'updated_at', 'reasoning_capabilities'}
|
||||||
|
}
|
||||||
|
_normalize_llm_reasoning(merged_model_data)
|
||||||
|
model_data['reasoning_config'] = merged_model_data['reasoning_config']
|
||||||
|
|
||||||
|
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
|
||||||
|
model_entity = persistence_model.LLMModel(**_runtime_model_data(model_uuid, merged_model_data))
|
||||||
|
_validate_llm_reasoning_capability(model_entity, runtime_provider)
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.update(persistence_model.LLMModel)
|
sqlalchemy.update(persistence_model.LLMModel)
|
||||||
@@ -362,30 +400,15 @@ class LLMModelsService:
|
|||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
|
||||||
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
|
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
|
||||||
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
|
|
||||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||||
context,
|
context,
|
||||||
persistence_model.LLMModel(
|
model_entity,
|
||||||
**_runtime_model_data(
|
|
||||||
model_uuid,
|
|
||||||
{
|
|
||||||
key: value
|
|
||||||
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
|
|
||||||
if key not in {'provider', 'created_at', 'updated_at'}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
),
|
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
||||||
|
|
||||||
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete an LLM model"""
|
"""Delete an LLM model"""
|
||||||
if _is_cloud_runtime(self.ap):
|
|
||||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
|
||||||
if existing_model is None:
|
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
||||||
@@ -407,6 +430,7 @@ class LLMModelsService:
|
|||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
|
runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
|
||||||
else:
|
else:
|
||||||
|
_normalize_llm_reasoning(model_data)
|
||||||
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data)
|
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data)
|
||||||
|
|
||||||
extra_args = model_data.get('extra_args', {})
|
extra_args = model_data.get('extra_args', {})
|
||||||
@@ -479,10 +503,7 @@ class EmbeddingModelsService:
|
|||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_embedding_model(
|
async def create_embedding_model(
|
||||||
self,
|
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
|
||||||
context: TenantContext,
|
|
||||||
model_data: dict,
|
|
||||||
preserve_uuid: bool = False,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new embedding model"""
|
"""Create a new embedding model"""
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
@@ -506,7 +527,6 @@ class EmbeddingModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -565,17 +585,11 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_embedding_model(
|
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_uuid: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Update an existing embedding model"""
|
"""Update an existing embedding model"""
|
||||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -600,7 +614,6 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -635,11 +648,6 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete an embedding model"""
|
"""Delete an embedding model"""
|
||||||
if _is_cloud_runtime(self.ap):
|
|
||||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
|
||||||
if existing_model is None:
|
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||||
@@ -732,12 +740,7 @@ class RerankModelsService:
|
|||||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_rerank_model(
|
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_data: dict,
|
|
||||||
preserve_uuid: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""Create a new rerank model"""
|
"""Create a new rerank model"""
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
if not preserve_uuid:
|
if not preserve_uuid:
|
||||||
@@ -760,7 +763,6 @@ class RerankModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -819,17 +821,11 @@ class RerankModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_rerank_model(
|
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
model_uuid: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Update an existing rerank model"""
|
"""Update an existing rerank model"""
|
||||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -854,7 +850,6 @@ class RerankModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -889,11 +884,6 @@ class RerankModelsService:
|
|||||||
|
|
||||||
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete a rerank model"""
|
"""Delete a rerank model"""
|
||||||
if _is_cloud_runtime(self.ap):
|
|
||||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
|
||||||
if existing_model is None:
|
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
|
||||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.RerankModel).where(
|
sqlalchemy.delete(persistence_model.RerankModel).where(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import traceback
|
|||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
@@ -21,20 +20,6 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
def _is_cloud_runtime(self) -> bool:
|
|
||||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
|
||||||
return getattr(mode, 'value', None) == 'cloud_runtime'
|
|
||||||
|
|
||||||
def _system_requester_is_reserved(self, requester: object) -> bool:
|
|
||||||
return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
|
|
||||||
async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None:
|
|
||||||
if not self._is_cloud_runtime():
|
|
||||||
return
|
|
||||||
provider = await self.get_provider(context, provider_uuid)
|
|
||||||
if provider is not None and self._system_requester_is_reserved(provider.get('requester')):
|
|
||||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
if api_keys is None:
|
if api_keys is None:
|
||||||
@@ -114,8 +99,6 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(
|
provider_data['api_keys'] = self._normalize_api_keys(
|
||||||
@@ -132,10 +115,7 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
await self._assert_provider_mutable(context, provider_uuid)
|
|
||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
|
||||||
provider_data.pop('uuid', None)
|
provider_data.pop('uuid', None)
|
||||||
provider_data.pop('workspace_uuid', None)
|
provider_data.pop('workspace_uuid', None)
|
||||||
if 'api_keys' in provider_data:
|
if 'api_keys' in provider_data:
|
||||||
@@ -165,7 +145,6 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||||
"""Delete a provider (only if no models reference it)"""
|
"""Delete a provider (only if no models reference it)"""
|
||||||
await self._assert_provider_mutable(context, provider_uuid)
|
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
# Check if any models use this provider
|
# Check if any models use this provider
|
||||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -266,8 +245,6 @@ class ModelProviderService:
|
|||||||
api_keys: list,
|
api_keys: list,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
if self._system_requester_is_reserved(requester):
|
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ class SpaceService:
|
|||||||
result_list = result.all()
|
result_list = result.all()
|
||||||
return result_list[0] if result_list else None
|
return result_list[0] if result_list else None
|
||||||
|
|
||||||
async def get_valid_access_token(self, user_email: str) -> str | None:
|
|
||||||
"""Return a current Space bearer, refreshing and persisting it when needed."""
|
|
||||||
return await self._ensure_valid_token(user_email)
|
|
||||||
|
|
||||||
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
||||||
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
||||||
user_obj = await self._get_user_by_email(user_email)
|
user_obj = await self._get_user_by_email(user_email)
|
||||||
@@ -121,12 +117,7 @@ class SpaceService:
|
|||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
|
|
||||||
async def exchange_oauth_code(
|
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
||||||
self,
|
|
||||||
code: str,
|
|
||||||
workspace_uuids: list[str] | None = None,
|
|
||||||
workspace_created_ats: dict[str, int] | None = None,
|
|
||||||
) -> typing.Dict:
|
|
||||||
"""Exchange OAuth authorization code for tokens"""
|
"""Exchange OAuth authorization code for tokens"""
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
@@ -136,14 +127,7 @@ class SpaceService:
|
|||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f'{space_url}/api/v1/accounts/oauth/token',
|
f'{space_url}/api/v1/accounts/oauth/token',
|
||||||
json={
|
json={'code': code, 'instance_id': constants.instance_id},
|
||||||
'code': code,
|
|
||||||
'instance_id': constants.instance_id,
|
|
||||||
# Sending an explicit empty list tells new Space servers not to
|
|
||||||
# synthesize a legacy instance-derived Workspace binding.
|
|
||||||
'workspace_uuids': workspace_uuids if workspace_uuids is not None else [],
|
|
||||||
'workspace_created_ats': workspace_created_ats or {},
|
|
||||||
},
|
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error = await httpclient.read_text_limited(response)
|
error = await httpclient.read_text_limited(response)
|
||||||
|
|||||||
@@ -779,27 +779,8 @@ class UserService:
|
|||||||
local_account = await self.get_user_by_email(user_email)
|
local_account = await self.get_user_by_email(user_email)
|
||||||
if local_account is None:
|
if local_account is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
# Exchange code for tokens and bind both installation and the active
|
# Exchange code for tokens
|
||||||
# OSS Workspace as independent identities.
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
|
||||||
if workspace_service is not None:
|
|
||||||
binding = await workspace_service.get_execution_binding()
|
|
||||||
created_at = binding.workspace_created_at
|
|
||||||
created_ts = (
|
|
||||||
int(created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
|
|
||||||
if created_at.tzinfo is None
|
|
||||||
else int(created_at.timestamp())
|
|
||||||
)
|
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
|
||||||
code,
|
|
||||||
[binding.workspace_uuid],
|
|
||||||
{binding.workspace_uuid: created_ts},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Compatibility for early/bootstrap call sites that have not wired
|
|
||||||
# WorkspaceService yet; old Space servers still derive the legacy
|
|
||||||
# Workspace identity from instance_id when the field is omitted.
|
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ from typing import Any, Protocol, runtime_checkable
|
|||||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||||
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
||||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||||
from .model_catalog import CloudModelCatalogProvider
|
|
||||||
|
|
||||||
|
|
||||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||||
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
||||||
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536, 3072})
|
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
|
||||||
|
|
||||||
|
|
||||||
class CloudBootstrapError(RuntimeError):
|
class CloudBootstrapError(RuntimeError):
|
||||||
@@ -51,7 +50,6 @@ class OpenSourceDeployment:
|
|||||||
)
|
)
|
||||||
directory_provider: None = None
|
directory_provider: None = None
|
||||||
manifest_provider: None = None
|
manifest_provider: None = None
|
||||||
model_catalog_provider: None = None
|
|
||||||
persistence_mode: str = 'oss_compat'
|
persistence_mode: str = 'oss_compat'
|
||||||
required_vector_backend: str | None = None
|
required_vector_backend: str | None = None
|
||||||
|
|
||||||
@@ -82,7 +80,6 @@ class VerifiedCloudDeployment:
|
|||||||
entitlement_provider: EntitlementProvider
|
entitlement_provider: EntitlementProvider
|
||||||
directory_provider: DirectoryProjectionProvider
|
directory_provider: DirectoryProjectionProvider
|
||||||
manifest_provider: CloudManifestProvider
|
manifest_provider: CloudManifestProvider
|
||||||
model_catalog_provider: CloudModelCatalogProvider
|
|
||||||
verification_key_id: str
|
verification_key_id: str
|
||||||
mode: str = dataclasses.field(default='cloud', init=False)
|
mode: str = dataclasses.field(default='cloud', init=False)
|
||||||
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
||||||
@@ -113,8 +110,6 @@ class VerifiedCloudDeployment:
|
|||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
||||||
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
||||||
if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider):
|
|
||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter')
|
|
||||||
|
|
||||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -143,14 +138,8 @@ class VerifiedCloudDeployment:
|
|||||||
if plugin_worker.get('require_hard_limits') is not True:
|
if plugin_worker.get('require_hard_limits') is not True:
|
||||||
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
||||||
box_config = config.get('box', {})
|
box_config = config.get('box', {})
|
||||||
box_enabled = box_config.get('enabled')
|
if box_config.get('enabled') is not True:
|
||||||
if box_enabled is False:
|
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
||||||
# Explicitly disabling Box removes the sandbox surface entirely and
|
|
||||||
# therefore does not weaken tenant isolation. Validate the strict
|
|
||||||
# runtime/admission contract only when the surface is enabled.
|
|
||||||
return
|
|
||||||
if box_enabled is not True:
|
|
||||||
raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean')
|
|
||||||
if box_config.get('backend') != 'nsjail':
|
if box_config.get('backend') != 'nsjail':
|
||||||
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
||||||
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
||||||
|
|||||||
@@ -358,7 +358,6 @@ class DirectoryProjectionService:
|
|||||||
|
|
||||||
await self._reconcile_entitlement_snapshot_set(snapshot)
|
await self._reconcile_entitlement_snapshot_set(snapshot)
|
||||||
self._publish_runtime_execution_projection(snapshot.workspaces)
|
self._publish_runtime_execution_projection(snapshot.workspaces)
|
||||||
self._request_model_catalog_sync()
|
|
||||||
self._record_batch_cardinality(
|
self._record_batch_cardinality(
|
||||||
active_workspaces=active_workspace_count,
|
active_workspaces=active_workspace_count,
|
||||||
workspaces=workspace_count,
|
workspaces=workspace_count,
|
||||||
@@ -467,7 +466,6 @@ class DirectoryProjectionService:
|
|||||||
returned.values(),
|
returned.values(),
|
||||||
affected_workspace_uuids=requested,
|
affected_workspace_uuids=requested,
|
||||||
)
|
)
|
||||||
self._request_model_catalog_sync()
|
|
||||||
self._record_batch_cardinality(
|
self._record_batch_cardinality(
|
||||||
active_workspaces=active_workspace_count,
|
active_workspaces=active_workspace_count,
|
||||||
workspaces=workspace_count,
|
workspaces=workspace_count,
|
||||||
@@ -477,14 +475,6 @@ class DirectoryProjectionService:
|
|||||||
self._record_success()
|
self._record_success()
|
||||||
self._consumer_cursor = batch.cursor
|
self._consumer_cursor = batch.cursor
|
||||||
|
|
||||||
def _request_model_catalog_sync(self) -> None:
|
|
||||||
"""Wake model provisioning after a committed directory change."""
|
|
||||||
|
|
||||||
service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
|
||||||
request_sync = getattr(service, 'request_sync', None)
|
|
||||||
if callable(request_sync):
|
|
||||||
request_sync()
|
|
||||||
|
|
||||||
def _publish_runtime_execution_projection(
|
def _publish_runtime_execution_projection(
|
||||||
self,
|
self,
|
||||||
workspaces: Iterable[DirectoryWorkspace],
|
workspaces: Iterable[DirectoryWorkspace],
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
||||||
|
|
||||||
from ..entity.persistence import model as persistence_model
|
|
||||||
|
|
||||||
|
|
||||||
LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
|
|
||||||
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
|
|
||||||
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
|
|
||||||
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
|
|
||||||
_MODEL_TABLES = (
|
|
||||||
persistence_model.LLMModel,
|
|
||||||
persistence_model.EmbeddingModel,
|
|
||||||
persistence_model.RerankModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogItem(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
uuid: str = Field(min_length=1, max_length=255)
|
|
||||||
model_id: str = Field(min_length=1, max_length=255)
|
|
||||||
category: Literal['chat', 'embedding', 'rerank']
|
|
||||||
llm_abilities: tuple[str, ...] = ()
|
|
||||||
is_featured: bool = False
|
|
||||||
featured_order: int = 0
|
|
||||||
|
|
||||||
@field_validator('llm_abilities', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def normalize_missing_abilities(cls, value: Any) -> Any:
|
|
||||||
return () if value is None else value
|
|
||||||
|
|
||||||
@field_validator('llm_abilities')
|
|
||||||
@classmethod
|
|
||||||
def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
||||||
if any(not item.strip() or len(item) > 64 for item in value):
|
|
||||||
raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
|
|
||||||
if len(set(value)) != len(value):
|
|
||||||
raise ValueError('Model abilities must be unique')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class CloudWorkspaceModelBilling(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
workspace_uuid: str = Field(min_length=36, max_length=36)
|
|
||||||
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
|
|
||||||
api_key: SecretStr | None = None
|
|
||||||
credits: int | None = None
|
|
||||||
|
|
||||||
@field_validator('workspace_uuid')
|
|
||||||
@classmethod
|
|
||||||
def validate_uuid(cls, value: str) -> str:
|
|
||||||
return str(uuid.UUID(value))
|
|
||||||
|
|
||||||
@field_validator('owner_account_uuid')
|
|
||||||
@classmethod
|
|
||||||
def validate_optional_uuid(cls, value: str | None) -> str | None:
|
|
||||||
return None if value is None else str(uuid.UUID(value))
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogSnapshot(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
instance_uuid: str = Field(min_length=1, max_length=255)
|
|
||||||
generated_at: datetime
|
|
||||||
base_url: str = Field(min_length=1, max_length=512)
|
|
||||||
models: tuple[CloudModelCatalogItem, ...]
|
|
||||||
workspaces: tuple[CloudWorkspaceModelBilling, ...]
|
|
||||||
|
|
||||||
@field_validator('base_url')
|
|
||||||
@classmethod
|
|
||||||
def validate_base_url(cls, value: str) -> str:
|
|
||||||
normalized = value.rstrip('/')
|
|
||||||
if not normalized.startswith('https://'):
|
|
||||||
raise ValueError('Cloud model gateway base URL must use HTTPS')
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@field_validator('models')
|
|
||||||
@classmethod
|
|
||||||
def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
|
|
||||||
if len(value) > 500:
|
|
||||||
raise ValueError('Cloud model catalog exceeds 500 models')
|
|
||||||
identities = {(item.category, item.uuid) for item in value}
|
|
||||||
if len(identities) != len(value):
|
|
||||||
raise ValueError('Cloud model catalog contains duplicate model identities')
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator('workspaces')
|
|
||||||
@classmethod
|
|
||||||
def validate_workspaces(
|
|
||||||
cls, value: tuple[CloudWorkspaceModelBilling, ...]
|
|
||||||
) -> tuple[CloudWorkspaceModelBilling, ...]:
|
|
||||||
if len(value) > 10_000:
|
|
||||||
raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
|
|
||||||
identities = {item.workspace_uuid for item in value}
|
|
||||||
if len(identities) != len(value):
|
|
||||||
raise ValueError('Cloud model catalog contains duplicate Workspaces')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class CloudModelCatalogProvider(Protocol):
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
"""Fetch and verify the complete model catalog and Workspace billing projection."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def system_provider_uuid(workspace_uuid: str) -> str:
|
|
||||||
workspace = str(uuid.UUID(workspace_uuid))
|
|
||||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))
|
|
||||||
|
|
||||||
|
|
||||||
def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
|
|
||||||
workspace = str(uuid.UUID(workspace_uuid))
|
|
||||||
if category not in _SUPPORTED_CATEGORIES:
|
|
||||||
raise ValueError(f'Unsupported model category: {category}')
|
|
||||||
if not upstream_uuid:
|
|
||||||
raise ValueError('Upstream model UUID is required')
|
|
||||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogSyncService:
|
|
||||||
"""Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: Any,
|
|
||||||
provider: CloudModelCatalogProvider,
|
|
||||||
instance_uuid: str,
|
|
||||||
*,
|
|
||||||
sync_interval_seconds: float = 3600.0,
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(provider, CloudModelCatalogProvider):
|
|
||||||
raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
|
|
||||||
if sync_interval_seconds < 10:
|
|
||||||
raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
|
|
||||||
self.ap = ap
|
|
||||||
self.provider = provider
|
|
||||||
self.instance_uuid = instance_uuid
|
|
||||||
self.sync_interval_seconds = float(sync_interval_seconds)
|
|
||||||
# A tenant UoW commits one Workspace at a time. Keep a durable in-memory
|
|
||||||
# convergence marker so a failed runtime reload is retried even when the
|
|
||||||
# following database reconciliation is a no-op.
|
|
||||||
self._runtime_reload_pending = False
|
|
||||||
self._workspace_credits: dict[str, int | None] = {}
|
|
||||||
self._sync_requested = asyncio.Event()
|
|
||||||
|
|
||||||
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
|
|
||||||
"""Return the latest signed owner-credit projection for a Workspace."""
|
|
||||||
return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
await self.sync_once(reload_runtime=False)
|
|
||||||
|
|
||||||
def request_sync(self) -> None:
|
|
||||||
"""Wake the catalog loop after a directory Workspace change."""
|
|
||||||
|
|
||||||
self._sync_requested.set()
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
|
|
||||||
except TimeoutError:
|
|
||||||
pass
|
|
||||||
self._sync_requested.clear()
|
|
||||||
try:
|
|
||||||
await self.sync_once(reload_runtime=True)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
# Exception messages can contain rendered SQL bound values,
|
|
||||||
# including provider API keys. Log only the exception class.
|
|
||||||
self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})')
|
|
||||||
|
|
||||||
async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
|
|
||||||
summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
snapshot: CloudModelCatalogSnapshot | None = None
|
|
||||||
sync_error: Exception | None = None
|
|
||||||
reload_error: Exception | None = None
|
|
||||||
try:
|
|
||||||
snapshot = await self.provider.fetch_model_catalog(self.instance_uuid)
|
|
||||||
if snapshot.instance_uuid != self.instance_uuid:
|
|
||||||
raise ValueError('Cloud model catalog targets another LangBot instance')
|
|
||||||
|
|
||||||
bindings = await self.ap.workspace_service.list_active_execution_bindings()
|
|
||||||
billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces}
|
|
||||||
missing = sorted(
|
|
||||||
binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace
|
|
||||||
)
|
|
||||||
if missing:
|
|
||||||
raise ValueError(
|
|
||||||
f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces'
|
|
||||||
)
|
|
||||||
|
|
||||||
for binding in bindings:
|
|
||||||
counts = await self._sync_workspace(
|
|
||||||
binding.workspace_uuid,
|
|
||||||
snapshot,
|
|
||||||
billing_by_workspace[binding.workspace_uuid],
|
|
||||||
)
|
|
||||||
summary['workspaces'] += 1
|
|
||||||
workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted'))
|
|
||||||
if workspace_changed:
|
|
||||||
# _sync_workspace returns only after its tenant UoW commits.
|
|
||||||
self._runtime_reload_pending = True
|
|
||||||
for key in ('created', 'updated', 'deleted'):
|
|
||||||
summary[key] += counts[key]
|
|
||||||
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
|
|
||||||
except Exception as exc:
|
|
||||||
sync_error = exc
|
|
||||||
finally:
|
|
||||||
model_mgr = getattr(self.ap, 'model_mgr', None)
|
|
||||||
if reload_runtime and self._runtime_reload_pending and model_mgr is not None:
|
|
||||||
try:
|
|
||||||
await model_mgr.load_models_from_db()
|
|
||||||
except Exception as exc:
|
|
||||||
reload_error = exc
|
|
||||||
else:
|
|
||||||
self._runtime_reload_pending = False
|
|
||||||
|
|
||||||
if sync_error is not None:
|
|
||||||
if reload_error is not None:
|
|
||||||
raise sync_error from reload_error
|
|
||||||
raise sync_error
|
|
||||||
if reload_error is not None:
|
|
||||||
raise reload_error
|
|
||||||
|
|
||||||
changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted'))
|
|
||||||
if changed and snapshot is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
'Cloud model catalog synchronized '
|
|
||||||
f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, '
|
|
||||||
f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})'
|
|
||||||
)
|
|
||||||
return summary
|
|
||||||
|
|
||||||
async def _sync_workspace(
|
|
||||||
self,
|
|
||||||
workspace_uuid: str,
|
|
||||||
snapshot: CloudModelCatalogSnapshot,
|
|
||||||
billing: CloudWorkspaceModelBilling,
|
|
||||||
) -> dict[str, int]:
|
|
||||||
counts = {'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
provider_uuid = system_provider_uuid(workspace_uuid)
|
|
||||||
desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else []
|
|
||||||
|
|
||||||
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
|
||||||
provider = await uow.session.scalar(
|
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
provider_values = {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'name': LANGBOT_MODELS_PROVIDER_NAME,
|
|
||||||
'requester': LANGBOT_MODELS_PROVIDER_REQUESTER,
|
|
||||||
'base_url': snapshot.base_url,
|
|
||||||
'api_keys': desired_keys,
|
|
||||||
}
|
|
||||||
if provider is None:
|
|
||||||
provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values)
|
|
||||||
uow.session.add(provider)
|
|
||||||
await uow.session.flush()
|
|
||||||
counts['created'] += 1
|
|
||||||
elif self._update_entity(provider, provider_values):
|
|
||||||
counts['updated'] += 1
|
|
||||||
|
|
||||||
existing_by_table: dict[type, dict[str, Any]] = {}
|
|
||||||
for table in _MODEL_TABLES:
|
|
||||||
rows = (
|
|
||||||
await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid))
|
|
||||||
).all()
|
|
||||||
existing_by_table[table] = {row.uuid: row for row in rows}
|
|
||||||
|
|
||||||
desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES}
|
|
||||||
for item in snapshot.models:
|
|
||||||
table, values = self._model_values(workspace_uuid, provider_uuid, item)
|
|
||||||
model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid)
|
|
||||||
desired_ids[table].add(model_uuid)
|
|
||||||
existing = existing_by_table[table].get(model_uuid)
|
|
||||||
if existing is None:
|
|
||||||
uow.session.add(table(uuid=model_uuid, **values))
|
|
||||||
counts['created'] += 1
|
|
||||||
elif self._update_entity(existing, values):
|
|
||||||
counts['updated'] += 1
|
|
||||||
|
|
||||||
for table, entities in existing_by_table.items():
|
|
||||||
for model_uuid, entity in entities.items():
|
|
||||||
if model_uuid not in desired_ids[table]:
|
|
||||||
await uow.session.delete(entity)
|
|
||||||
counts['deleted'] += 1
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _update_entity(entity: Any, values: dict[str, Any]) -> bool:
|
|
||||||
changed = False
|
|
||||||
for key, value in values.items():
|
|
||||||
if getattr(entity, key) != value:
|
|
||||||
setattr(entity, key, value)
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _model_values(
|
|
||||||
workspace_uuid: str,
|
|
||||||
provider_uuid: str,
|
|
||||||
item: CloudModelCatalogItem,
|
|
||||||
) -> tuple[type, dict[str, Any]]:
|
|
||||||
ranking = 100 - item.featured_order if item.is_featured else 0
|
|
||||||
common = {
|
|
||||||
'workspace_uuid': workspace_uuid,
|
|
||||||
'name': item.model_id,
|
|
||||||
'provider_uuid': provider_uuid,
|
|
||||||
'extra_args': {},
|
|
||||||
'prefered_ranking': ranking,
|
|
||||||
}
|
|
||||||
if item.category == 'chat':
|
|
||||||
return persistence_model.LLMModel, {
|
|
||||||
**common,
|
|
||||||
'abilities': list(item.llm_abilities),
|
|
||||||
'context_length': None,
|
|
||||||
}
|
|
||||||
if item.category == 'embedding':
|
|
||||||
return persistence_model.EmbeddingModel, common
|
|
||||||
if item.category == 'rerank':
|
|
||||||
return persistence_model.RerankModel, common
|
|
||||||
raise ValueError(f'Unsupported model category: {item.category}')
|
|
||||||
@@ -54,7 +54,6 @@ from ..cloud import launch as cloud_launch_module
|
|||||||
from ..cloud import support_admin as cloud_support_admin_module
|
from ..cloud import support_admin as cloud_support_admin_module
|
||||||
from ..cloud import directory_projection as cloud_directory_projection_module
|
from ..cloud import directory_projection as cloud_directory_projection_module
|
||||||
from ..cloud import entitlements as cloud_entitlements_module
|
from ..cloud import entitlements as cloud_entitlements_module
|
||||||
from ..cloud import model_catalog as cloud_model_catalog_module
|
|
||||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
|
|
||||||
|
|
||||||
@@ -143,12 +142,13 @@ class Application:
|
|||||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||||
|
|
||||||
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
||||||
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
|
||||||
cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None
|
|
||||||
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
||||||
|
|
||||||
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
||||||
|
|
||||||
|
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
||||||
|
|
||||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||||
|
|
||||||
http_ctrl: http_controller.HTTPController = None
|
http_ctrl: http_controller.HTTPController = None
|
||||||
@@ -306,12 +306,6 @@ class Application:
|
|||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
if self.cloud_model_catalog_service is not None:
|
|
||||||
self.task_mgr.create_task(
|
|
||||||
self.cloud_model_catalog_service.run(),
|
|
||||||
name='cloud-model-catalog-sync',
|
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
|
||||||
)
|
|
||||||
if self.manifest_refresh_service is not None:
|
if self.manifest_refresh_service is not None:
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
self.manifest_refresh_service.run(),
|
self.manifest_refresh_service.run(),
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ from ...cloud import support_admin as cloud_support_admin_module
|
|||||||
from ...cloud.directory import directory_projection_limits_from_config
|
from ...cloud.directory import directory_projection_limits_from_config
|
||||||
from ...cloud.directory_projection import DirectoryProjectionService
|
from ...cloud.directory_projection import DirectoryProjectionService
|
||||||
from ...cloud.entitlements import EntitlementResolver
|
from ...cloud.entitlements import EntitlementResolver
|
||||||
from ...cloud.model_catalog import CloudModelCatalogSyncService
|
|
||||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
from ...api.http.authz import WorkspaceRequiredError
|
from ...api.http.authz import WorkspaceRequiredError
|
||||||
|
|
||||||
@@ -177,16 +176,6 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
# of repeating tenant validation for every manager.
|
# of repeating tenant validation for every manager.
|
||||||
await workspace_service_inst.prime_startup_execution_bindings()
|
await workspace_service_inst.prime_startup_execution_bindings()
|
||||||
|
|
||||||
if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment):
|
|
||||||
raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment')
|
|
||||||
cloud_model_catalog_service = CloudModelCatalogSyncService(
|
|
||||||
ap,
|
|
||||||
deployment.model_catalog_provider,
|
|
||||||
constants.instance_id,
|
|
||||||
)
|
|
||||||
await cloud_model_catalog_service.initialize()
|
|
||||||
ap.cloud_model_catalog_service = cloud_model_catalog_service
|
|
||||||
|
|
||||||
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
||||||
ap,
|
ap,
|
||||||
workspace_service_inst,
|
workspace_service_inst,
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ class LLMModel(Base):
|
|||||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||||
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
||||||
|
reasoning_config = sqlalchemy.Column(
|
||||||
|
sqlalchemy.JSON,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: {'level': 'provider_default'},
|
||||||
|
server_default=sqlalchemy.text('\'{"level":"provider_default"}\''),
|
||||||
|
)
|
||||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
|||||||
@@ -163,13 +163,6 @@ class WorkspaceMembership(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
||||||
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
||||||
sqlalchemy.Index(
|
|
||||||
'uq_workspace_memberships_one_active_owner',
|
|
||||||
'workspace_uuid',
|
|
||||||
unique=True,
|
|
||||||
sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
|
||||||
postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
|
||||||
),
|
|
||||||
sqlalchemy.CheckConstraint(
|
sqlalchemy.CheckConstraint(
|
||||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||||
name='ck_workspace_memberships_role',
|
name='ck_workspace_memberships_role',
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""add durable replay protection for signed Space launch assertions
|
|
||||||
|
|
||||||
Revision ID: 0016_space_launch_replay
|
|
||||||
Revises: 0015_cloud_core_collab
|
|
||||||
Create Date: 2026-07-31
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0016_space_launch_replay'
|
|
||||||
down_revision = '0015_cloud_core_collab'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_TABLE = 'space_launch_assertion_consumptions'
|
|
||||||
_POLICY = 'langbot_directory_projection'
|
|
||||||
_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')"
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if _TABLE not in set(sa.inspect(conn).get_table_names()):
|
|
||||||
op.create_table(
|
|
||||||
_TABLE,
|
|
||||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
|
||||||
sa.Column('jti', sa.String(255), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('instance_uuid', 'jti'),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
'ix_space_launch_assertion_consumptions_expiry',
|
|
||||||
_TABLE,
|
|
||||||
['instance_uuid', 'expires_at'],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
if conn.dialect.name == 'postgresql':
|
|
||||||
table = conn.dialect.identifier_preparer.quote(_TABLE)
|
|
||||||
policy = conn.dialect.identifier_preparer.quote(_POLICY)
|
|
||||||
expression = f'instance_uuid::text = {_SETTING}'
|
|
||||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
|
||||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
|
||||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
|
||||||
f'USING ({expression}) WITH CHECK ({expression})'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()):
|
|
||||||
op.drop_table(_TABLE)
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""add llm reasoning config
|
||||||
|
|
||||||
|
Revision ID: 0018_llm_reasoning_config
|
||||||
|
Revises: 0017_oss_workspace_identity
|
||||||
|
Create Date: 2026-07-27
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0018_llm_reasoning_config'
|
||||||
|
down_revision = '0017_oss_workspace_identity'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_LLM_MODELS = sa.table(
|
||||||
|
'llm_models',
|
||||||
|
sa.column('reasoning_config', sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'llm_models' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||||
|
if 'reasoning_config' in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
'llm_models',
|
||||||
|
sa.Column(
|
||||||
|
'reasoning_config',
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
server_default=sa.text('\'{"level":"provider_default"}\''),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(_LLM_MODELS.update().values(reasoning_config={'level': 'provider_default'}))
|
||||||
|
with op.batch_alter_table('llm_models') as batch_op:
|
||||||
|
batch_op.alter_column('reasoning_config', existing_type=sa.JSON(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'llm_models' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||||
|
if 'reasoning_config' in columns:
|
||||||
|
with op.batch_alter_table('llm_models') as batch_op:
|
||||||
|
batch_op.drop_column('reasoning_config')
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""merge the published Space launch replay and main migration branches
|
|
||||||
|
|
||||||
Revision ID: 0018_merge_launch_replay
|
|
||||||
Revises: 0016_space_launch_replay, 0017_oss_workspace_identity
|
|
||||||
Create Date: 2026-08-01
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
revision = '0018_merge_launch_replay'
|
|
||||||
down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""enforce one active owner per Workspace
|
|
||||||
|
|
||||||
Revision ID: 0019_single_workspace_owner
|
|
||||||
Revises: 0018_merge_launch_replay
|
|
||||||
Create Date: 2026-08-02
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0019_single_workspace_owner'
|
|
||||||
down_revision = '0018_merge_launch_replay'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Ownership transfer used to promote a second member without demoting the
|
|
||||||
# original owner. Preserve the Workspace creator where possible and demote
|
|
||||||
# every historical extra owner before installing the database invariant.
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
WITH ranked_owners AS (
|
|
||||||
SELECT membership.uuid,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY membership.workspace_uuid
|
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
COALESCE(membership.joined_at, membership.created_at),
|
|
||||||
membership.uuid
|
|
||||||
) AS owner_rank
|
|
||||||
FROM workspace_memberships AS membership
|
|
||||||
JOIN workspaces AS workspace
|
|
||||||
ON workspace.uuid = membership.workspace_uuid
|
|
||||||
WHERE membership.role = 'owner'
|
|
||||||
AND membership.status = 'active'
|
|
||||||
)
|
|
||||||
UPDATE workspace_memberships
|
|
||||||
SET role = 'admin'
|
|
||||||
WHERE uuid IN (
|
|
||||||
SELECT uuid
|
|
||||||
FROM ranked_owners
|
|
||||||
WHERE owner_rank > 1
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Fresh installations may already have this index because SQLAlchemy
|
|
||||||
# metadata is created before Alembic advances the revision marker.
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
|
||||||
'uq_workspace_memberships_one_active_owner '
|
|
||||||
'ON workspace_memberships (workspace_uuid) '
|
|
||||||
"WHERE role = 'owner' AND status = 'active'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
|
|
||||||
if _INDEX_NAME in index_names:
|
|
||||||
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""enable 3072-dimensional pgvector embeddings
|
|
||||||
|
|
||||||
Revision ID: 001a_pgvector_dimension_3072
|
|
||||||
Revises: 0019_single_workspace_owner
|
|
||||||
Create Date: 2026-08-05
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '001a_pgvector_dimension_3072'
|
|
||||||
down_revision = '0019_single_workspace_owner'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
_TABLE = 'langbot_vectors'
|
|
||||||
_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
|
|
||||||
_INDEX = 'ix_langbot_vectors_hnsw_cosine_3072'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
|
|
||||||
return
|
|
||||||
op.drop_constraint(_CHECK, _TABLE, type_='check')
|
|
||||||
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536, 3072)')
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
f'CREATE INDEX {_INDEX} ON {_TABLE} USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops) WHERE embedding_dimension = 3072'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
|
|
||||||
return
|
|
||||||
count = conn.scalar(sa.text(f'SELECT COUNT(*) FROM {_TABLE} WHERE embedding_dimension = 3072'))
|
|
||||||
if count:
|
|
||||||
raise RuntimeError('Cannot disable 3072-dimensional pgvector while matching embeddings exist')
|
|
||||||
op.drop_index(_INDEX, table_name=_TABLE)
|
|
||||||
op.drop_constraint(_CHECK, _TABLE, type_='check')
|
|
||||||
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536)')
|
|
||||||
@@ -98,7 +98,7 @@ _WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
|||||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
||||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||||
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
|
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
|
||||||
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
|
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||||
_RUNTIME_SCHEMA = 'public'
|
_RUNTIME_SCHEMA = 'public'
|
||||||
_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
|
_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
|
||||||
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
|
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
|
||||||
@@ -1356,16 +1356,14 @@ class PersistenceManager:
|
|||||||
index = by_index.get(index_name)
|
index = by_index.get(index_name)
|
||||||
index_definition = normalized(None if index is None else index['definition'])
|
index_definition = normalized(None if index is None else index['definition'])
|
||||||
predicate = normalized(None if index is None else index['predicate'])
|
predicate = normalized(None if index is None else index['predicate'])
|
||||||
vector_type = 'halfvec' if dimension > 2000 else 'vector'
|
|
||||||
operator_class = f'{vector_type}_cosine_ops'
|
|
||||||
if (
|
if (
|
||||||
index is None
|
index is None
|
||||||
or index['access_method'] != 'hnsw'
|
or index['access_method'] != 'hnsw'
|
||||||
or index['is_valid'] is not True
|
or index['is_valid'] is not True
|
||||||
or index['is_ready'] is not True
|
or index['is_ready'] is not True
|
||||||
or f'{vector_type}({dimension})' not in index_definition
|
or f'vector({dimension})' not in index_definition
|
||||||
or f'(embedding)::{vector_type}({dimension})' not in index_definition
|
or f'(embedding)::vector({dimension})' not in index_definition
|
||||||
or operator_class not in index_definition
|
or 'vector_cosine_ops' not in index_definition
|
||||||
or predicate.strip('() ') != f'embedding_dimension = {dimension}'
|
or predicate.strip('() ') != f'embedding_dimension = {dimension}'
|
||||||
):
|
):
|
||||||
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
|
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import typing
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||||
import sqlalchemy.orm as sqlalchemy_orm
|
import sqlalchemy.orm as sqlalchemy_orm
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
|
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
|
||||||
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
|
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
|
||||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
||||||
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
|||||||
'now': sqlalchemy.sql.functions.now,
|
'now': sqlalchemy.sql.functions.now,
|
||||||
'sum': sqlalchemy.sql.functions.sum,
|
'sum': sqlalchemy.sql.functions.sum,
|
||||||
}
|
}
|
||||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
|
||||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||||
sqlalchemy.sql.dml.UpdateBase,
|
sqlalchemy.sql.dml.UpdateBase,
|
||||||
@@ -281,7 +281,7 @@ def _validate_scoped_sql_type(
|
|||||||
return
|
return
|
||||||
seen.add(identity)
|
seen.add(identity)
|
||||||
|
|
||||||
if type(sql_type) in {Vector, HALFVEC}:
|
if type(sql_type) is Vector:
|
||||||
return
|
return
|
||||||
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
||||||
@@ -462,7 +462,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
|||||||
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
|
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
|
||||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
||||||
|
|
||||||
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
|
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
|
||||||
raise ScopedSessionTransactionError(
|
raise ScopedSessionTransactionError(
|
||||||
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class Controller:
|
|||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not selected_query: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
if not selected_query: # 没有请求,或所有 query 对应的 session 都已达到并发上限
|
||||||
await self.ap.query_pool.condition.wait()
|
await self.ap.query_pool.condition.wait()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import contextvars
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -25,6 +26,15 @@ _current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.Context
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WebSocketReplyContext:
|
||||||
|
"""Trusted routing context retained when the originating socket reconnects."""
|
||||||
|
|
||||||
|
scope: WebSocketScope
|
||||||
|
pipeline_uuid: str
|
||||||
|
session_id: str | None
|
||||||
|
|
||||||
|
|
||||||
class WebSocketMessage(pydantic.BaseModel):
|
class WebSocketMessage(pydantic.BaseModel):
|
||||||
"""WebSocket消息格式"""
|
"""WebSocket消息格式"""
|
||||||
|
|
||||||
@@ -265,6 +275,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
embed_target = self._parse_embed_target(sender_id)
|
embed_target = self._parse_embed_target(sender_id)
|
||||||
if embed_target is not None:
|
if embed_target is not None:
|
||||||
return embed_target
|
return embed_target
|
||||||
|
reply_context = getattr(message_source, '_websocket_reply_context', None)
|
||||||
|
if isinstance(reply_context, WebSocketReplyContext):
|
||||||
|
if reply_context.scope != self._scope():
|
||||||
|
raise ValueError('WebSocket reply context does not match this adapter scope')
|
||||||
|
return reply_context.pipeline_uuid, reply_context.session_id
|
||||||
raise ValueError('WebSocket reply target is not bound to this adapter scope')
|
raise ValueError('WebSocket reply target is not bound to this adapter scope')
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
@@ -685,6 +700,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
|
|
||||||
# 异步触发事件处理
|
# 异步触发事件处理
|
||||||
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
|
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
|
||||||
|
object.__setattr__(
|
||||||
|
event,
|
||||||
|
'_websocket_reply_context',
|
||||||
|
WebSocketReplyContext(
|
||||||
|
scope=connection.scope,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
session_id=connection.session_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
listeners = (
|
listeners = (
|
||||||
owner_bot.adapter.listeners
|
owner_bot.adapter.listeners
|
||||||
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
|
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
|
||||||
@@ -707,25 +732,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if len(listener_tasks) >= 100:
|
if len(listener_tasks) >= 100:
|
||||||
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
|
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
|
||||||
return
|
return
|
||||||
listener = typing.cast(
|
async def run_listener() -> None:
|
||||||
typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
|
|
||||||
listeners[event.__class__],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run_listener():
|
|
||||||
token = _current_pipeline_uuid.set(pipeline_uuid)
|
token = _current_pipeline_uuid.set(pipeline_uuid)
|
||||||
try:
|
try:
|
||||||
await listener(event, callback_adapter)
|
await listeners[event.__class__](event, callback_adapter)
|
||||||
finally:
|
finally:
|
||||||
_current_pipeline_uuid.reset(token)
|
_current_pipeline_uuid.reset(token)
|
||||||
|
|
||||||
listener_coro = run_listener()
|
|
||||||
task_manager = getattr(self.ap, 'task_mgr', None)
|
task_manager = getattr(self.ap, 'task_mgr', None)
|
||||||
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
||||||
listener_task = asyncio.create_task(listener_coro)
|
listener_task = asyncio.create_task(run_listener())
|
||||||
else:
|
else:
|
||||||
listener_task = task_manager.create_task(
|
listener_task = task_manager.create_task(
|
||||||
listener_coro,
|
run_listener(),
|
||||||
kind='websocket-message',
|
kind='websocket-message',
|
||||||
name=f'websocket-message-{connection.connection_id}',
|
name=f'websocket-message-{connection.connection_id}',
|
||||||
scopes=[
|
scopes=[
|
||||||
|
|||||||
@@ -251,8 +251,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
||||||
if not self._control_token and allow_generate:
|
if not self._control_token and allow_generate:
|
||||||
self._control_token = secrets.token_urlsafe(48)
|
self._control_token = secrets.token_urlsafe(48)
|
||||||
if not self._control_token:
|
|
||||||
return {}
|
|
||||||
try:
|
try:
|
||||||
self._control_token = validate_runtime_secret(
|
self._control_token = validate_runtime_secret(
|
||||||
self._control_token,
|
self._control_token,
|
||||||
@@ -1970,11 +1968,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
with runtime_handler.installation_scope(binding):
|
with runtime_handler.installation_scope(binding):
|
||||||
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||||
|
|
||||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
if not self.is_enable_plugin or not self._runtime_available():
|
if not self.is_enable_plugin or not self._runtime_available():
|
||||||
return {}
|
return {}
|
||||||
return await self._runtime_handler().get_debug_info(execution_context)
|
return await self._runtime_handler().get_debug_info()
|
||||||
|
|
||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1960,14 +1960,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
result = await self.call_action(
|
with self.installation_scope(None):
|
||||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
result = await self.call_action(
|
||||||
{},
|
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||||
timeout=10,
|
{},
|
||||||
action_context=execution_context,
|
timeout=10,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ class ModelManager:
|
|||||||
provider_uuid=runtime_provider.provider_entity.uuid,
|
provider_uuid=runtime_provider.provider_entity.uuid,
|
||||||
abilities=model_info.get('abilities', []),
|
abilities=model_info.get('abilities', []),
|
||||||
context_length=model_info.get('context_length'),
|
context_length=model_info.get('context_length'),
|
||||||
|
reasoning_config=model_info.get('reasoning_config', {'level': 'provider_default'}),
|
||||||
extra_args=model_info.get('extra_args', {}),
|
extra_args=model_info.get('extra_args', {}),
|
||||||
)
|
)
|
||||||
return self._build_llm_model(execution_context, model_entity, runtime_provider)
|
return self._build_llm_model(execution_context, model_entity, runtime_provider)
|
||||||
@@ -717,7 +718,10 @@ class ModelManager:
|
|||||||
provider_entity = self._coerce_provider(provider_info, context)
|
provider_entity = self._coerce_provider(provider_info, context)
|
||||||
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
||||||
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
|
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
|
||||||
config = {'base_url': provider_entity.base_url}
|
config = {
|
||||||
|
'base_url': provider_entity.base_url,
|
||||||
|
'requester_name': provider_entity.requester,
|
||||||
|
}
|
||||||
|
|
||||||
if litellm_provider:
|
if litellm_provider:
|
||||||
from .requesters import litellmchat
|
from .requesters import litellmchat
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
|
||||||
|
ReasoningLevel = typing.Literal[
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'enabled',
|
||||||
|
'minimal',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
'max',
|
||||||
|
]
|
||||||
|
|
||||||
|
REASONING_LEVELS: tuple[str, ...] = (
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'enabled',
|
||||||
|
'minimal',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
'max',
|
||||||
|
)
|
||||||
|
DEFAULT_REASONING_CONFIG: dict[str, str] = {'level': 'provider_default'}
|
||||||
|
|
||||||
|
_CONFLICTING_TOP_LEVEL_ARGS = {
|
||||||
|
'reasoning_effort',
|
||||||
|
'thinking',
|
||||||
|
'enable_thinking',
|
||||||
|
'thinking_budget',
|
||||||
|
'reasoning',
|
||||||
|
}
|
||||||
|
_CONFLICTING_EXTRA_BODY_ARGS = {
|
||||||
|
'reasoning_effort',
|
||||||
|
'thinking',
|
||||||
|
'enable_thinking',
|
||||||
|
'thinking_budget',
|
||||||
|
'reasoning',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_reasoning_config(value: typing.Any) -> dict[str, str]:
|
||||||
|
"""Return the canonical model reasoning configuration."""
|
||||||
|
if value is None:
|
||||||
|
return dict(DEFAULT_REASONING_CONFIG)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError('reasoning_config must be an object')
|
||||||
|
|
||||||
|
unknown_fields = set(value) - {'level'}
|
||||||
|
if unknown_fields:
|
||||||
|
raise ValueError(f'Unsupported reasoning_config fields: {", ".join(sorted(unknown_fields))}')
|
||||||
|
|
||||||
|
level = value.get('level', 'provider_default')
|
||||||
|
if level not in REASONING_LEVELS:
|
||||||
|
raise ValueError(f'Unsupported reasoning level: {level}')
|
||||||
|
return {'level': typing.cast(str, level)}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_reasoning_config(
|
||||||
|
value: typing.Any,
|
||||||
|
abilities: typing.Iterable[str] | None,
|
||||||
|
extra_args: typing.Any,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Validate a model-facing reasoning config and conflicting raw arguments."""
|
||||||
|
config = normalize_reasoning_config(value)
|
||||||
|
if config['level'] == 'provider_default':
|
||||||
|
return config
|
||||||
|
|
||||||
|
if 'reasoning' not in set(abilities or []):
|
||||||
|
raise ValueError('The reasoning ability must be enabled before selecting a reasoning level')
|
||||||
|
|
||||||
|
conflicts = find_reasoning_arg_conflicts(extra_args)
|
||||||
|
if conflicts:
|
||||||
|
raise ValueError('reasoning_config conflicts with advanced parameters: ' + ', '.join(conflicts))
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def find_reasoning_arg_conflicts(extra_args: typing.Any) -> list[str]:
|
||||||
|
if not isinstance(extra_args, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
conflicts = [key for key in sorted(_CONFLICTING_TOP_LEVEL_ARGS) if key in extra_args]
|
||||||
|
extra_body = extra_args.get('extra_body')
|
||||||
|
if isinstance(extra_body, dict):
|
||||||
|
conflicts.extend(f'extra_body.{key}' for key in sorted(_CONFLICTING_EXTRA_BODY_ARGS) if key in extra_body)
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def validate_reasoning_capabilities(
|
||||||
|
config: typing.Any,
|
||||||
|
capabilities: typing.Mapping[str, typing.Any],
|
||||||
|
model_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure an explicit reasoning level can be honored by the requester."""
|
||||||
|
level = normalize_reasoning_config(config)['level']
|
||||||
|
if level == 'provider_default':
|
||||||
|
return
|
||||||
|
|
||||||
|
available_levels = capabilities.get('levels')
|
||||||
|
if not isinstance(available_levels, list):
|
||||||
|
available_levels = []
|
||||||
|
legacy_levels = capabilities.get('legacy_levels')
|
||||||
|
if not isinstance(legacy_levels, list):
|
||||||
|
legacy_levels = []
|
||||||
|
if capabilities.get('supported') is not True or (
|
||||||
|
level not in available_levels and level not in legacy_levels
|
||||||
|
):
|
||||||
|
available_text = ', '.join(str(item) for item in available_levels) or 'provider_default'
|
||||||
|
raise ValueError(
|
||||||
|
f'Reasoning level "{level}" is not supported by model {model_name}. Available levels: {available_text}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_reasoning_capabilities(
|
||||||
|
supported: bool = False,
|
||||||
|
source: str = 'unknown',
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
return {
|
||||||
|
'supported': supported,
|
||||||
|
'levels': ['provider_default'],
|
||||||
|
'source': source,
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ from ...entity.persistence import model as persistence_model
|
|||||||
from ...workspace.errors import WorkspaceInvariantError
|
from ...workspace.errors import WorkspaceInvariantError
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
from . import token
|
from . import token
|
||||||
|
from . import reasoning
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
@@ -377,11 +378,15 @@ class RuntimeLLMModel:
|
|||||||
provider: RuntimeProvider
|
provider: RuntimeProvider
|
||||||
"""提供商实例"""
|
"""提供商实例"""
|
||||||
|
|
||||||
|
reasoning_config_override: dict[str, str] | None
|
||||||
|
"""Request-scoped reasoning policy supplied by the active pipeline."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
model_entity: persistence_model.LLMModel,
|
model_entity: persistence_model.LLMModel,
|
||||||
provider: RuntimeProvider,
|
provider: RuntimeProvider,
|
||||||
|
reasoning_config_override: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
|
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
|
||||||
if model_entity.workspace_uuid != execution_context.workspace_uuid:
|
if model_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||||
@@ -391,6 +396,7 @@ class RuntimeLLMModel:
|
|||||||
self.execution_context = execution_context
|
self.execution_context = execution_context
|
||||||
self.model_entity = model_entity
|
self.model_entity = model_entity
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
self.reasoning_config_override = reasoning_config_override
|
||||||
|
|
||||||
|
|
||||||
class RuntimeEmbeddingModel:
|
class RuntimeEmbeddingModel:
|
||||||
@@ -482,6 +488,13 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError('This provider does not support model scanning')
|
raise NotImplementedError('This provider does not support model scanning')
|
||||||
|
|
||||||
|
def get_reasoning_capabilities(self, model: RuntimeLLMModel) -> dict[str, typing.Any]:
|
||||||
|
"""Return normalized reasoning controls supported by a model."""
|
||||||
|
return reasoning.default_reasoning_capabilities(
|
||||||
|
supported='reasoning' in (model.model_entity.abilities or []),
|
||||||
|
source='manual' if 'reasoning' in (model.model_entity.abilities or []) else 'unknown',
|
||||||
|
)
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def invoke_llm(
|
async def invoke_llm(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import typing
|
|||||||
import litellm
|
import litellm
|
||||||
from litellm import acompletion, aembedding, arerank
|
from litellm import acompletion, aembedding, arerank
|
||||||
|
|
||||||
from .. import errors, requester
|
from .. import errors, reasoning, requester
|
||||||
from ....utils import httpclient
|
from ....utils import httpclient
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -164,6 +164,39 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
_EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
|
_EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
|
||||||
_RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank')
|
_RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank')
|
||||||
|
_QWEN_DEDICATED_THINKING_MODELS = frozenset(
|
||||||
|
{
|
||||||
|
'qwen3.7-max-preview',
|
||||||
|
'qwen3.7-max-2026-05-17',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_QWEN_REASONING_BUDGETS = {
|
||||||
|
'low': 1024,
|
||||||
|
'medium': 4096,
|
||||||
|
'high': 8192,
|
||||||
|
}
|
||||||
|
_INFERRED_EFFORT_PROVIDERS = frozenset(
|
||||||
|
{
|
||||||
|
'anthropic',
|
||||||
|
'gemini',
|
||||||
|
'groq',
|
||||||
|
'mistral',
|
||||||
|
'openai',
|
||||||
|
'openrouter',
|
||||||
|
'together_ai',
|
||||||
|
'xai',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_REQUESTER_REASONING_FAMILIES = {
|
||||||
|
'openai-chat-completions': 'openai',
|
||||||
|
'anthropic-messages': 'anthropic',
|
||||||
|
'deepseek-chat-completions': 'deepseek',
|
||||||
|
'moonshot-chat-completions': 'kimi',
|
||||||
|
'moonshot-cn-chat-completions': 'kimi',
|
||||||
|
'bailian-chat-completions': 'qwen',
|
||||||
|
'doubao-chat-completions': 'doubao',
|
||||||
|
'mimo-chat-completions': 'mimo',
|
||||||
|
}
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
default_config: dict[str, typing.Any] = {
|
||||||
'base_url': '',
|
'base_url': '',
|
||||||
@@ -172,6 +205,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
'drop_params': False,
|
'drop_params': False,
|
||||||
'num_retries': 0,
|
'num_retries': 0,
|
||||||
'api_version': '',
|
'api_version': '',
|
||||||
|
'requester_name': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
@@ -201,7 +235,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
provider = self._get_custom_llm_provider()
|
provider = self._get_custom_llm_provider()
|
||||||
candidates: list[tuple[str, str | None]] = [(model_name, provider)]
|
candidates: list[tuple[str, str | None]] = [
|
||||||
|
(candidate, None) for candidate in self._metadata_model_candidates(model_name)
|
||||||
|
]
|
||||||
|
candidates.append((model_name, provider))
|
||||||
litellm_model_name = self._build_litellm_model_name(model_name)
|
litellm_model_name = self._build_litellm_model_name(model_name)
|
||||||
if litellm_model_name != model_name:
|
if litellm_model_name != model_name:
|
||||||
candidates.append((litellm_model_name, None))
|
candidates.append((litellm_model_name, None))
|
||||||
@@ -268,6 +305,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
deduped_candidates.append(candidate)
|
deduped_candidates.append(candidate)
|
||||||
return deduped_candidates
|
return deduped_candidates
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _metadata_model_candidates(model_name: str) -> list[str]:
|
||||||
|
"""Return known equivalent model IDs used only for LiteLLM metadata lookup."""
|
||||||
|
normalized_model_name = (model_name or '').lower()
|
||||||
|
if normalized_model_name.startswith('mimo-v2.5'):
|
||||||
|
return [f'openrouter/xiaomi/{normalized_model_name}']
|
||||||
|
return []
|
||||||
|
|
||||||
def _known_context_length_fallback(self, model_name: str) -> int | None:
|
def _known_context_length_fallback(self, model_name: str) -> int | None:
|
||||||
normalized_model_name = (model_name or '').lower()
|
normalized_model_name = (model_name or '').lower()
|
||||||
if normalized_model_name.startswith('deepseek-v4-'):
|
if normalized_model_name.startswith('deepseek-v4-'):
|
||||||
@@ -287,7 +332,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
if not callable(helper):
|
if not callable(helper):
|
||||||
return self._known_context_length_fallback(model_name)
|
return self._known_context_length_fallback(model_name)
|
||||||
|
|
||||||
candidates = [model_name]
|
candidates = self._metadata_model_candidates(model_name)
|
||||||
|
candidates.append(model_name)
|
||||||
litellm_model_name = self._build_litellm_model_name(model_name)
|
litellm_model_name = self._build_litellm_model_name(model_name)
|
||||||
if litellm_model_name != model_name:
|
if litellm_model_name != model_name:
|
||||||
candidates.append(litellm_model_name)
|
candidates.append(litellm_model_name)
|
||||||
@@ -314,6 +360,297 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
def _supports_vision(self, model_name: str) -> bool:
|
def _supports_vision(self, model_name: str) -> bool:
|
||||||
return self._safe_litellm_bool_helper('supports_vision', model_name)
|
return self._safe_litellm_bool_helper('supports_vision', model_name)
|
||||||
|
|
||||||
|
def _supports_reasoning(self, model_name: str) -> bool:
|
||||||
|
return self._safe_litellm_bool_helper('supports_reasoning', model_name)
|
||||||
|
|
||||||
|
def _requester_name(self, model: requester.RuntimeLLMModel | None = None) -> str:
|
||||||
|
if model is not None:
|
||||||
|
provider_entity = getattr(getattr(model, 'provider', None), 'provider_entity', None)
|
||||||
|
name = getattr(provider_entity, 'requester', None)
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
return name.lower()
|
||||||
|
return str(self.requester_cfg.get('requester_name') or '').lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_reasoning_family_from_model_name(model_name: str) -> str:
|
||||||
|
normalized_name = (model_name or '').lower()
|
||||||
|
basename = normalized_name.rsplit('/', 1)[-1]
|
||||||
|
if basename.startswith(('gpt-', 'chatgpt-', 'o1', 'o3', 'o4')):
|
||||||
|
return 'openai'
|
||||||
|
if basename.startswith('claude-'):
|
||||||
|
return 'anthropic'
|
||||||
|
if basename.startswith('deepseek-'):
|
||||||
|
return 'deepseek'
|
||||||
|
if basename.startswith(('kimi-', 'moonshot-')):
|
||||||
|
return 'kimi'
|
||||||
|
if basename.startswith(('qwen-', 'qwen3', 'qwq')):
|
||||||
|
return 'qwen'
|
||||||
|
if basename.startswith(('doubao-', 'seed-')):
|
||||||
|
return 'doubao'
|
||||||
|
if basename.startswith('mimo-'):
|
||||||
|
return 'mimo'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _reasoning_family(
|
||||||
|
self,
|
||||||
|
model_name: str,
|
||||||
|
model: requester.RuntimeLLMModel | None = None,
|
||||||
|
) -> str:
|
||||||
|
requester_name = self._requester_name(model)
|
||||||
|
if requester_name in {'new-api-chat-completions', 'volcark-chat-completions'}:
|
||||||
|
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
|
||||||
|
if inferred_family:
|
||||||
|
return inferred_family
|
||||||
|
return 'volcengine' if requester_name == 'volcark-chat-completions' else ''
|
||||||
|
|
||||||
|
# Bailian's compatible endpoint also hosts Kimi models. Keep those
|
||||||
|
# models on Kimi's ``thinking`` protocol instead of Qwen's
|
||||||
|
# ``enable_thinking`` protocol.
|
||||||
|
if requester_name == 'bailian-chat-completions':
|
||||||
|
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
|
||||||
|
if inferred_family == 'kimi':
|
||||||
|
return inferred_family
|
||||||
|
|
||||||
|
requester_family = self._REQUESTER_REASONING_FAMILIES.get(requester_name)
|
||||||
|
if requester_family:
|
||||||
|
return requester_family
|
||||||
|
|
||||||
|
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
|
||||||
|
provider = (self._get_custom_llm_provider() or '').lower()
|
||||||
|
if provider == 'openai':
|
||||||
|
return inferred_family or ('openai' if requester_name in {'', 'openai'} else '')
|
||||||
|
if provider:
|
||||||
|
return provider
|
||||||
|
return inferred_family
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_anthropic_adaptive_model(model_name: str) -> bool:
|
||||||
|
basename = model_name.lower().rsplit('/', 1)[-1]
|
||||||
|
if 'mythos-preview' in basename:
|
||||||
|
return True
|
||||||
|
|
||||||
|
parts = basename.split('-')
|
||||||
|
if len(parts) < 3 or parts[0] != 'claude':
|
||||||
|
return False
|
||||||
|
model_families = {'opus', 'sonnet', 'fable', 'mythos'}
|
||||||
|
if parts[1] in model_families:
|
||||||
|
if parts[2] == '5':
|
||||||
|
return True
|
||||||
|
return len(parts) >= 4 and parts[2] == '4' and parts[3] in {'6', '7', '8'}
|
||||||
|
return parts[1] == '5' and parts[2] in model_families
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_anthropic_always_thinking_model(model_name: str) -> bool:
|
||||||
|
normalized_name = model_name.lower()
|
||||||
|
return any(marker in normalized_name for marker in ('fable-5', 'mythos-5', 'mythos-preview'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_dedicated_qwen_thinking_model(model_name: str) -> bool:
|
||||||
|
normalized_name = model_name.lower().rsplit('/', 1)[-1]
|
||||||
|
return (
|
||||||
|
normalized_name in LiteLLMRequester._QWEN_DEDICATED_THINKING_MODELS
|
||||||
|
or normalized_name.startswith('qwq')
|
||||||
|
or '-thinking' in normalized_name
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _supports_qwen_thinking_budget(model_name: str) -> bool:
|
||||||
|
"""Return whether the documented Qwen3 family supports thinking_budget."""
|
||||||
|
normalized_name = model_name.lower().rsplit('/', 1)[-1]
|
||||||
|
return normalized_name.startswith('qwen3')
|
||||||
|
|
||||||
|
def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None:
|
||||||
|
normalized_name = model_name.lower().rsplit('/', 1)[-1]
|
||||||
|
|
||||||
|
if family == 'deepseek' and normalized_name.startswith('deepseek-'):
|
||||||
|
if normalized_name.startswith('deepseek-v4-'):
|
||||||
|
return ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
|
||||||
|
if 'reasoner' in normalized_name or '-r1' in normalized_name:
|
||||||
|
return ['provider_default']
|
||||||
|
return ['provider_default', 'disabled', 'enabled']
|
||||||
|
|
||||||
|
if family == 'kimi':
|
||||||
|
if normalized_name.startswith('kimi-k3'):
|
||||||
|
return ['provider_default', 'low', 'high', 'max']
|
||||||
|
if normalized_name.startswith('kimi-k2.7-code'):
|
||||||
|
return ['provider_default']
|
||||||
|
if normalized_name.startswith(('kimi-k2.5', 'kimi-k2.6')):
|
||||||
|
return ['provider_default', 'disabled', 'enabled']
|
||||||
|
if 'thinking' in normalized_name:
|
||||||
|
return ['provider_default']
|
||||||
|
|
||||||
|
if family == 'qwen' and normalized_name.startswith(('qwen-', 'qwen3', 'qwq')):
|
||||||
|
if self._is_dedicated_qwen_thinking_model(normalized_name):
|
||||||
|
if self._supports_qwen_thinking_budget(normalized_name):
|
||||||
|
return ['provider_default', 'low', 'medium', 'high']
|
||||||
|
return ['provider_default']
|
||||||
|
if self._supports_qwen_thinking_budget(normalized_name):
|
||||||
|
return ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
|
return ['provider_default', 'disabled', 'enabled']
|
||||||
|
|
||||||
|
if family == 'doubao' and normalized_name.startswith(('doubao-', 'seed-')):
|
||||||
|
return ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
if family == 'mimo' and normalized_name.startswith(('mimo-v2.5',)):
|
||||||
|
return ['provider_default', 'disabled', 'enabled']
|
||||||
|
|
||||||
|
if family == 'anthropic' and normalized_name.startswith('claude-'):
|
||||||
|
levels = ['provider_default']
|
||||||
|
adaptive = self._is_anthropic_adaptive_model(normalized_name)
|
||||||
|
if adaptive and not self._is_anthropic_always_thinking_model(normalized_name):
|
||||||
|
levels.append('disabled')
|
||||||
|
levels.extend(['low', 'medium', 'high'])
|
||||||
|
if adaptive:
|
||||||
|
levels.extend(['xhigh', 'max'])
|
||||||
|
return levels
|
||||||
|
|
||||||
|
if family == 'openai' and normalized_name.startswith(('gpt-5', 'o1', 'o3', 'o4')):
|
||||||
|
return ['provider_default', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _openai_reasoning_levels(self, model_name: str) -> list[str]:
|
||||||
|
model_info = self._safe_model_info(model_name)
|
||||||
|
levels = ['provider_default']
|
||||||
|
if model_info.get('supports_none_reasoning_effort') is True:
|
||||||
|
levels.append('disabled')
|
||||||
|
if model_info.get('supports_minimal_reasoning_effort') is True:
|
||||||
|
levels.append('minimal')
|
||||||
|
for level in ('low', 'medium', 'high'):
|
||||||
|
if model_info.get(f'supports_{level}_reasoning_effort') is not False:
|
||||||
|
levels.append(level)
|
||||||
|
for level in ('xhigh', 'max'):
|
||||||
|
if model_info.get(f'supports_{level}_reasoning_effort') is True:
|
||||||
|
levels.append(level)
|
||||||
|
return levels
|
||||||
|
|
||||||
|
def _safe_model_info(self, model_name: str) -> dict[str, typing.Any]:
|
||||||
|
helper = getattr(litellm, 'get_model_info', None)
|
||||||
|
if not callable(helper):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
*self._metadata_model_candidates(model_name),
|
||||||
|
model_name,
|
||||||
|
self._build_litellm_model_name(model_name),
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
info = helper(candidate)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(info, dict):
|
||||||
|
return info
|
||||||
|
model_dump = getattr(info, 'model_dump', None)
|
||||||
|
if callable(model_dump):
|
||||||
|
try:
|
||||||
|
dumped = model_dump()
|
||||||
|
if isinstance(dumped, dict):
|
||||||
|
return dumped
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_reasoning_capabilities(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]:
|
||||||
|
model_name = model.model_entity.name
|
||||||
|
abilities = model.model_entity.abilities or []
|
||||||
|
detected = self._supports_reasoning(model_name)
|
||||||
|
declared = 'reasoning' in abilities
|
||||||
|
family = self._reasoning_family(model_name, model)
|
||||||
|
known_levels = self._known_reasoning_levels(model_name, family)
|
||||||
|
supported = detected or declared or known_levels is not None
|
||||||
|
if not supported:
|
||||||
|
return reasoning.default_reasoning_capabilities()
|
||||||
|
|
||||||
|
normalized_name = model_name.lower()
|
||||||
|
if family == 'openai':
|
||||||
|
levels = self._openai_reasoning_levels(model_name)
|
||||||
|
elif known_levels is not None:
|
||||||
|
levels = known_levels
|
||||||
|
elif family == 'anthropic':
|
||||||
|
levels = ['provider_default', 'low', 'medium', 'high']
|
||||||
|
elif family in {'deepseek', 'qwen', 'mimo', 'volcengine'}:
|
||||||
|
levels = ['provider_default', 'disabled', 'enabled']
|
||||||
|
elif family == 'doubao':
|
||||||
|
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
|
elif family == 'ollama':
|
||||||
|
levels = ['provider_default']
|
||||||
|
levels.append('disabled')
|
||||||
|
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||||
|
levels.extend(['low', 'medium', 'high'])
|
||||||
|
else:
|
||||||
|
levels.append('enabled')
|
||||||
|
elif family in self._INFERRED_EFFORT_PROVIDERS:
|
||||||
|
levels = ['provider_default', 'low', 'medium', 'high']
|
||||||
|
else:
|
||||||
|
levels = ['provider_default']
|
||||||
|
|
||||||
|
capabilities = {
|
||||||
|
'supported': True,
|
||||||
|
'levels': list(dict.fromkeys(levels)),
|
||||||
|
'source': 'litellm' if detected else ('provider' if known_levels is not None else 'manual'),
|
||||||
|
}
|
||||||
|
if family == 'qwen' and 'disabled' in capabilities['levels'] and 'enabled' not in capabilities['levels']:
|
||||||
|
capabilities['legacy_levels'] = ['enabled']
|
||||||
|
return capabilities
|
||||||
|
|
||||||
|
def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]:
|
||||||
|
level = self._reasoning_level(model)
|
||||||
|
if level == 'provider_default':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
config = {'level': level}
|
||||||
|
capabilities = self.get_reasoning_capabilities(model)
|
||||||
|
try:
|
||||||
|
reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise errors.RequesterError(str(exc)) from exc
|
||||||
|
|
||||||
|
family = self._reasoning_family(model.model_entity.name, model)
|
||||||
|
if level == 'disabled':
|
||||||
|
if family in {'deepseek', 'kimi', 'mimo', 'doubao'}:
|
||||||
|
return {'extra_body': {'thinking': {'type': 'disabled'}}}
|
||||||
|
if family == 'qwen':
|
||||||
|
return {'extra_body': {'enable_thinking': False}}
|
||||||
|
if family == 'volcengine':
|
||||||
|
return {'extra_body': {'thinking': {'type': 'disabled'}}}
|
||||||
|
if family == 'anthropic':
|
||||||
|
return {'thinking': {'type': 'disabled'}}
|
||||||
|
return {'reasoning_effort': 'none'}
|
||||||
|
if level == 'enabled':
|
||||||
|
if family in {'deepseek', 'kimi', 'mimo', 'volcengine'}:
|
||||||
|
return {'extra_body': {'thinking': {'type': 'enabled'}}}
|
||||||
|
if family == 'qwen':
|
||||||
|
return {'extra_body': {'enable_thinking': True}}
|
||||||
|
return {'reasoning_effort': 'low'}
|
||||||
|
if family == 'qwen' and level in self._QWEN_REASONING_BUDGETS:
|
||||||
|
return {
|
||||||
|
'extra_body': {
|
||||||
|
'enable_thinking': True,
|
||||||
|
'thinking_budget': self._QWEN_REASONING_BUDGETS[level],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if family == 'deepseek':
|
||||||
|
return {
|
||||||
|
'extra_body': {
|
||||||
|
'thinking': {'type': 'enabled'},
|
||||||
|
'reasoning_effort': level,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {'reasoning_effort': level}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reasoning_config_value(model: requester.RuntimeLLMModel) -> typing.Any:
|
||||||
|
raw_config = getattr(model, 'reasoning_config_override', None)
|
||||||
|
if raw_config is None:
|
||||||
|
raw_config = getattr(model.model_entity, 'reasoning_config', None)
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
return None
|
||||||
|
return raw_config
|
||||||
|
|
||||||
|
def _reasoning_level(self, model: requester.RuntimeLLMModel) -> str:
|
||||||
|
return reasoning.normalize_reasoning_config(self._reasoning_config_value(model))['level']
|
||||||
|
|
||||||
def _infer_model_type(self, model_id: str) -> str:
|
def _infer_model_type(self, model_id: str) -> str:
|
||||||
normalized_id = (model_id or '').lower()
|
normalized_id = (model_id or '').lower()
|
||||||
if any(kw in normalized_id for kw in self._RERANK_MODEL_HINTS):
|
if any(kw in normalized_id for kw in self._RERANK_MODEL_HINTS):
|
||||||
@@ -344,6 +681,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
)
|
)
|
||||||
if supports_provider_reported_vision or self._supports_vision(model_id):
|
if supports_provider_reported_vision or self._supports_vision(model_id):
|
||||||
abilities.append('vision')
|
abilities.append('vision')
|
||||||
|
supports_provider_reported_reasoning = bool(
|
||||||
|
model_payload and model_payload.get('supports_reasoning') is True
|
||||||
|
)
|
||||||
|
family = self._reasoning_family(model_id)
|
||||||
|
supports_known_reasoning = self._known_reasoning_levels(model_id, family) is not None
|
||||||
|
if supports_provider_reported_reasoning or supports_known_reasoning or self._supports_reasoning(model_id):
|
||||||
|
abilities.append('reasoning')
|
||||||
scanned_model['abilities'] = abilities
|
scanned_model['abilities'] = abilities
|
||||||
|
|
||||||
context_length = self._context_length_from_scan_payload(model_payload)
|
context_length = self._context_length_from_scan_payload(model_payload)
|
||||||
@@ -354,13 +698,43 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
return scanned_model
|
return scanned_model
|
||||||
|
|
||||||
def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]:
|
def _convert_messages(
|
||||||
|
self,
|
||||||
|
messages: typing.List[provider_message.Message],
|
||||||
|
reasoning_family: str = '',
|
||||||
|
include_reasoning_context: bool = True,
|
||||||
|
) -> list[dict]:
|
||||||
"""Convert LangBot messages to LiteLLM/OpenAI format."""
|
"""Convert LangBot messages to LiteLLM/OpenAI format."""
|
||||||
req_messages = []
|
req_messages = []
|
||||||
for m in messages:
|
for m in messages:
|
||||||
msg_dict = m.dict(exclude_none=True)
|
msg_dict = m.dict(exclude_none=True)
|
||||||
content = msg_dict.get('content')
|
content = msg_dict.get('content')
|
||||||
|
|
||||||
|
if msg_dict.get('role') == 'assistant' and reasoning_family:
|
||||||
|
provider_fields = msg_dict.get('provider_specific_fields')
|
||||||
|
if isinstance(provider_fields, dict):
|
||||||
|
cleaned_provider_fields = dict(provider_fields)
|
||||||
|
reasoning_content = cleaned_provider_fields.pop('reasoning_content', None)
|
||||||
|
thinking_blocks = cleaned_provider_fields.pop('thinking_blocks', None)
|
||||||
|
|
||||||
|
if include_reasoning_context:
|
||||||
|
if reasoning_family == 'anthropic' and thinking_blocks:
|
||||||
|
msg_dict['thinking_blocks'] = thinking_blocks
|
||||||
|
elif reasoning_family in {
|
||||||
|
'deepseek',
|
||||||
|
'kimi',
|
||||||
|
'qwen',
|
||||||
|
'doubao',
|
||||||
|
'mimo',
|
||||||
|
'volcengine',
|
||||||
|
} and isinstance(reasoning_content, str):
|
||||||
|
msg_dict['reasoning_content'] = reasoning_content
|
||||||
|
|
||||||
|
if cleaned_provider_fields:
|
||||||
|
msg_dict['provider_specific_fields'] = cleaned_provider_fields
|
||||||
|
else:
|
||||||
|
msg_dict.pop('provider_specific_fields', None)
|
||||||
|
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
converted_parts = []
|
converted_parts = []
|
||||||
for part in content:
|
for part in content:
|
||||||
@@ -651,7 +1025,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
stream: bool = False,
|
stream: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
|
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
|
||||||
req_messages = self._convert_messages(messages)
|
reasoning_family = self._reasoning_family(model.model_entity.name, model)
|
||||||
|
reasoning_level = self._reasoning_level(model)
|
||||||
|
req_messages = self._convert_messages(
|
||||||
|
messages,
|
||||||
|
reasoning_family=reasoning_family,
|
||||||
|
include_reasoning_context=reasoning_level != 'disabled',
|
||||||
|
)
|
||||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||||
api_key = model.provider.token_mgr.get_token()
|
api_key = model.provider.token_mgr.get_token()
|
||||||
|
|
||||||
@@ -670,6 +1050,29 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
args.update(model.model_entity.extra_args)
|
args.update(model.model_entity.extra_args)
|
||||||
args.update(extra_args)
|
args.update(extra_args)
|
||||||
|
|
||||||
|
reasoning_args = self._build_reasoning_args(model)
|
||||||
|
if reasoning_args:
|
||||||
|
conflicts = reasoning.find_reasoning_arg_conflicts(model.model_entity.extra_args)
|
||||||
|
conflicts.extend(reasoning.find_reasoning_arg_conflicts(extra_args))
|
||||||
|
if conflicts:
|
||||||
|
raise errors.RequesterError(
|
||||||
|
'reasoning_config conflicts with advanced parameters: ' + ', '.join(dict.fromkeys(conflicts))
|
||||||
|
)
|
||||||
|
reasoning_extra_body = reasoning_args.get('extra_body')
|
||||||
|
if isinstance(reasoning_extra_body, dict):
|
||||||
|
existing_extra_body = args.get('extra_body') or {}
|
||||||
|
if not isinstance(existing_extra_body, dict):
|
||||||
|
raise errors.RequesterError('extra_body must be an object')
|
||||||
|
args.update({key: value for key, value in reasoning_args.items() if key != 'extra_body'})
|
||||||
|
args['extra_body'] = {**existing_extra_body, **reasoning_extra_body}
|
||||||
|
else:
|
||||||
|
args.update(reasoning_args)
|
||||||
|
if 'reasoning_effort' in reasoning_args and self._get_custom_llm_provider() == 'openai':
|
||||||
|
allowed_openai_params = args.get('allowed_openai_params') or []
|
||||||
|
if not isinstance(allowed_openai_params, (list, tuple, set)):
|
||||||
|
raise errors.RequesterError('allowed_openai_params must be an array')
|
||||||
|
args['allowed_openai_params'] = list(dict.fromkeys([*allowed_openai_params, 'reasoning_effort']))
|
||||||
|
|
||||||
if funcs:
|
if funcs:
|
||||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
|
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
|
||||||
if tools:
|
if tools:
|
||||||
@@ -699,6 +1102,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
content = message_data.get('content', '')
|
content = message_data.get('content', '')
|
||||||
reasoning_content = message_data.get('reasoning_content', None)
|
reasoning_content = message_data.get('reasoning_content', None)
|
||||||
|
if reasoning_content:
|
||||||
|
provider_fields = dict(message_data.get('provider_specific_fields') or {})
|
||||||
|
provider_fields['reasoning_content'] = reasoning_content
|
||||||
|
message_data['provider_specific_fields'] = provider_fields
|
||||||
message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think)
|
message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think)
|
||||||
|
|
||||||
if 'reasoning_content' in message_data:
|
if 'reasoning_content' in message_data:
|
||||||
@@ -728,6 +1135,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
role = 'assistant'
|
role = 'assistant'
|
||||||
tool_call_state: dict[int, dict[str, typing.Any]] = {}
|
tool_call_state: dict[int, dict[str, typing.Any]] = {}
|
||||||
think_state = _ThinkStripState() if remove_think else None
|
think_state = _ThinkStripState() if remove_think else None
|
||||||
|
reasoning_started = False
|
||||||
|
reasoning_closed = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await acompletion(**args)
|
response = await acompletion(**args)
|
||||||
@@ -758,18 +1167,36 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
if 'role' in delta and delta['role']:
|
if 'role' in delta and delta['role']:
|
||||||
role = delta['role']
|
role = delta['role']
|
||||||
|
|
||||||
delta_content = delta.get('content', '')
|
delta_content = delta.get('content') or ''
|
||||||
reasoning_content = delta.get('reasoning_content', '')
|
reasoning_content = delta.get('reasoning_content') or ''
|
||||||
|
provider_fields = dict(delta.get('provider_specific_fields') or {})
|
||||||
|
|
||||||
# Handle reasoning_content based on remove_think flag
|
# Handle reasoning_content based on remove_think flag
|
||||||
if reasoning_content:
|
if reasoning_content:
|
||||||
|
provider_fields['reasoning_content'] = reasoning_content
|
||||||
if remove_think:
|
if remove_think:
|
||||||
# Skip reasoning content when remove_think is True
|
delta_content = delta_content or None
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
# Use reasoning_content as the displayed content
|
# Stream explicit markers so downstream adapters and
|
||||||
delta_content = reasoning_content
|
# the debug page see the same format as non-streaming
|
||||||
|
# responses.
|
||||||
|
if not reasoning_started:
|
||||||
|
delta_content = '<think>\n'
|
||||||
|
reasoning_started = True
|
||||||
|
else:
|
||||||
|
delta_content = ''
|
||||||
|
delta_content += reasoning_content
|
||||||
|
if delta.get('content'):
|
||||||
|
delta_content += f'\n</think>\n{delta.get("content")}'
|
||||||
|
reasoning_closed = True
|
||||||
|
|
||||||
|
elif delta_content and not remove_think and reasoning_started and not reasoning_closed:
|
||||||
|
delta_content = f'\n</think>\n{delta_content}'
|
||||||
|
reasoning_closed = True
|
||||||
|
|
||||||
|
if finish_reason and not remove_think and reasoning_started and not reasoning_closed:
|
||||||
|
delta_content = f'{delta_content}\n</think>\n'
|
||||||
|
reasoning_closed = True
|
||||||
|
|
||||||
if think_state is not None and delta_content:
|
if think_state is not None and delta_content:
|
||||||
delta_content = think_state.feed(delta_content)
|
delta_content = think_state.feed(delta_content)
|
||||||
@@ -779,7 +1206,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
|
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
|
||||||
|
|
||||||
if chunk_idx == 0 and not delta_content and not tool_calls:
|
if chunk_idx == 0 and not delta_content and not tool_calls and not provider_fields:
|
||||||
chunk_idx += 1
|
chunk_idx += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -791,13 +1218,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures)
|
# Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures)
|
||||||
if delta.get('provider_specific_fields'):
|
if provider_fields:
|
||||||
chunk_data['provider_specific_fields'] = delta['provider_specific_fields']
|
chunk_data['provider_specific_fields'] = provider_fields
|
||||||
|
|
||||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
||||||
yield provider_message.MessageChunk(**chunk_data)
|
yield provider_message.MessageChunk(**chunk_data)
|
||||||
chunk_idx += 1
|
chunk_idx += 1
|
||||||
|
|
||||||
|
if reasoning_started and not reasoning_closed:
|
||||||
|
yield provider_message.MessageChunk(
|
||||||
|
role=role,
|
||||||
|
content='\n</think>\n',
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
if think_state is not None:
|
if think_state is not None:
|
||||||
pending_content = think_state.flush()
|
pending_content = think_state.flush()
|
||||||
if pending_content:
|
if pending_content:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import typing
|
|||||||
from .. import runner
|
from .. import runner
|
||||||
from ...telemetry import features as telemetry_features
|
from ...telemetry import features as telemetry_features
|
||||||
from ..modelmgr import requester as modelmgr_requester
|
from ..modelmgr import requester as modelmgr_requester
|
||||||
|
from ..modelmgr import reasoning as modelmgr_reasoning
|
||||||
from ..tools.loaders.native import EXEC_TOOL_NAME
|
from ..tools.loaders.native import EXEC_TOOL_NAME
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
@@ -60,6 +61,7 @@ class _StreamAccumulator:
|
|||||||
self.msg_idx = 0
|
self.msg_idx = 0
|
||||||
self.accumulated_content = initial_content or ''
|
self.accumulated_content = initial_content or ''
|
||||||
self.last_role = 'assistant'
|
self.last_role = 'assistant'
|
||||||
|
self.provider_specific_fields: dict[str, typing.Any] = {}
|
||||||
self.msg_sequence = msg_sequence
|
self.msg_sequence = msg_sequence
|
||||||
self.remove_think = remove_think
|
self.remove_think = remove_think
|
||||||
self._think_state = None
|
self._think_state = None
|
||||||
@@ -94,6 +96,14 @@ class _StreamAccumulator:
|
|||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
|
|
||||||
|
if msg.provider_specific_fields:
|
||||||
|
for key, value in msg.provider_specific_fields.items():
|
||||||
|
if key == 'reasoning_content' and isinstance(value, str):
|
||||||
|
previous = self.provider_specific_fields.get(key, '')
|
||||||
|
self.provider_specific_fields[key] = f'{previous}{value}'
|
||||||
|
else:
|
||||||
|
self.provider_specific_fields[key] = value
|
||||||
|
|
||||||
if msg.is_final:
|
if msg.is_final:
|
||||||
self._flush_think_state()
|
self._flush_think_state()
|
||||||
|
|
||||||
@@ -103,6 +113,7 @@ class _StreamAccumulator:
|
|||||||
role=self.last_role,
|
role=self.last_role,
|
||||||
content=self._maybe_strip_think(self.accumulated_content),
|
content=self._maybe_strip_think(self.accumulated_content),
|
||||||
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
|
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
|
||||||
|
provider_specific_fields=(self.provider_specific_fields or None) if msg.is_final else None,
|
||||||
is_final=msg.is_final,
|
is_final=msg.is_final,
|
||||||
msg_sequence=self.msg_sequence,
|
msg_sequence=self.msg_sequence,
|
||||||
)
|
)
|
||||||
@@ -115,6 +126,7 @@ class _StreamAccumulator:
|
|||||||
role=self.last_role,
|
role=self.last_role,
|
||||||
content=self._maybe_strip_think(self.accumulated_content),
|
content=self._maybe_strip_think(self.accumulated_content),
|
||||||
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
|
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
|
||||||
|
provider_specific_fields=self.provider_specific_fields or None,
|
||||||
msg_sequence=self.msg_sequence,
|
msg_sequence=self.msg_sequence,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,9 +245,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
execution_context,
|
execution_context,
|
||||||
query.use_llm_model_uuid,
|
query.use_llm_model_uuid,
|
||||||
)
|
)
|
||||||
candidates.append(primary)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
||||||
|
else:
|
||||||
|
candidates.append(LocalAgentRunner._apply_pipeline_reasoning_config(query, primary))
|
||||||
|
|
||||||
# Fallback models
|
# Fallback models
|
||||||
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
||||||
@@ -245,12 +258,31 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
execution_context,
|
execution_context,
|
||||||
fb_uuid,
|
fb_uuid,
|
||||||
)
|
)
|
||||||
candidates.append(fb_model)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||||
|
else:
|
||||||
|
candidates.append(LocalAgentRunner._apply_pipeline_reasoning_config(query, fb_model))
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_pipeline_reasoning_config(
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
model: modelmgr_requester.RuntimeLLMModel,
|
||||||
|
) -> modelmgr_requester.RuntimeLLMModel:
|
||||||
|
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||||
|
model_config = local_agent_config.get('model', {})
|
||||||
|
reasoning_by_model = model_config.get('reasoning', {}) if isinstance(model_config, dict) else {}
|
||||||
|
level = (
|
||||||
|
reasoning_by_model.get(model.model_entity.uuid, 'provider_default')
|
||||||
|
if isinstance(reasoning_by_model, dict)
|
||||||
|
else 'provider_default'
|
||||||
|
)
|
||||||
|
reasoning_config = modelmgr_reasoning.normalize_reasoning_config({'level': level})
|
||||||
|
configured_model = copy.copy(model)
|
||||||
|
configured_model.reasoning_config_override = reasoning_config
|
||||||
|
return configured_model
|
||||||
|
|
||||||
async def _invoke_with_fallback(
|
async def _invoke_with_fallback(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class WorkspaceResourceSnapshot(typing.TypedDict):
|
|||||||
extension_count: int
|
extension_count: int
|
||||||
skill_count: int
|
skill_count: int
|
||||||
adapters: list[str]
|
adapters: list[str]
|
||||||
execution_generation: int
|
|
||||||
|
|
||||||
|
|
||||||
async def _count(
|
async def _count(
|
||||||
@@ -82,7 +81,6 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
|
|||||||
'extension_count': 0,
|
'extension_count': 0,
|
||||||
'skill_count': 0,
|
'skill_count': 0,
|
||||||
'adapters': [],
|
'adapters': [],
|
||||||
'execution_generation': binding.placement_generation,
|
|
||||||
}
|
}
|
||||||
for binding in bindings
|
for binding in bindings
|
||||||
}
|
}
|
||||||
@@ -120,7 +118,6 @@ async def build_heartbeat_payload(
|
|||||||
ap: core_app.Application,
|
ap: core_app.Application,
|
||||||
*,
|
*,
|
||||||
workspace_uuid: str,
|
workspace_uuid: str,
|
||||||
workspace_create_ts: int = 0,
|
|
||||||
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Collect one anonymous Workspace profile snapshot."""
|
"""Collect one anonymous Workspace profile snapshot."""
|
||||||
@@ -213,9 +210,7 @@ async def build_heartbeat_payload(
|
|||||||
'event_type': 'instance_heartbeat',
|
'event_type': 'instance_heartbeat',
|
||||||
'query_id': '',
|
'query_id': '',
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
|
||||||
'workspace_uuid': workspace_uuid,
|
'workspace_uuid': workspace_uuid,
|
||||||
'workspace_create_ts': workspace_create_ts,
|
|
||||||
'instance_create_ts': constants.instance_create_ts,
|
'instance_create_ts': constants.instance_create_ts,
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'features': features,
|
'features': features,
|
||||||
@@ -223,24 +218,10 @@ async def build_heartbeat_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _workspace_created_timestamp(created_at: datetime | None) -> int:
|
|
||||||
if created_at is None:
|
|
||||||
return 0
|
|
||||||
if created_at.tzinfo is None:
|
|
||||||
# SQLAlchemy may return persisted UTC values without tzinfo. Never
|
|
||||||
# reinterpret them in the host's local timezone.
|
|
||||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
||||||
return int(created_at.timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
||||||
"""Build one heartbeat per active Workspace."""
|
"""Build one heartbeat per active Workspace."""
|
||||||
bindings = await ap.workspace_service.list_active_execution_bindings()
|
bindings = await ap.workspace_service.list_active_execution_bindings()
|
||||||
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
||||||
workspace_create_ts = {
|
|
||||||
binding.workspace_uuid: _workspace_created_timestamp(getattr(binding, 'workspace_created_at', None))
|
|
||||||
for binding in bindings
|
|
||||||
}
|
|
||||||
resources = {
|
resources = {
|
||||||
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
||||||
}
|
}
|
||||||
@@ -248,7 +229,6 @@ async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
|||||||
await build_heartbeat_payload(
|
await build_heartbeat_payload(
|
||||||
ap,
|
ap,
|
||||||
workspace_uuid=workspace_uuid,
|
workspace_uuid=workspace_uuid,
|
||||||
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
|
|
||||||
workspace_resource=resources.get(workspace_uuid),
|
workspace_resource=resources.get(workspace_uuid),
|
||||||
)
|
)
|
||||||
for workspace_uuid in workspace_uuids
|
for workspace_uuid in workspace_uuids
|
||||||
|
|||||||
@@ -4,19 +4,13 @@ import typing
|
|||||||
|
|
||||||
|
|
||||||
class WorkspaceExecutionContext(typing.Protocol):
|
class WorkspaceExecutionContext(typing.Protocol):
|
||||||
@property
|
|
||||||
def instance_uuid(self) -> str: ...
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workspace_uuid(self) -> str: ...
|
def workspace_uuid(self) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
||||||
"""Build both first-class telemetry identities for one execution."""
|
"""Build the canonical telemetry identity for one Workspace execution."""
|
||||||
instance_id = execution_context.instance_uuid.strip()
|
|
||||||
workspace_uuid = execution_context.workspace_uuid.strip()
|
workspace_uuid = execution_context.workspace_uuid.strip()
|
||||||
if not instance_id:
|
|
||||||
raise ValueError('Telemetry execution instance ID is empty')
|
|
||||||
if not workspace_uuid:
|
if not workspace_uuid:
|
||||||
raise ValueError('Telemetry execution Workspace UUID is empty')
|
raise ValueError('Telemetry execution Workspace UUID is empty')
|
||||||
return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
|
return {'workspace_uuid': workspace_uuid}
|
||||||
|
|||||||
@@ -136,31 +136,12 @@ class TelemetryManager:
|
|||||||
try:
|
try:
|
||||||
# Use asyncio.wait_for to ensure we always bound the total time
|
# Use asyncio.wait_for to ensure we always bound the total time
|
||||||
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
||||||
headers: dict[str, str] = {}
|
|
||||||
if telemetry_token:
|
if telemetry_token:
|
||||||
headers['X-LangBot-Telemetry-Token'] = telemetry_token
|
request = client.post(
|
||||||
else:
|
url,
|
||||||
workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
|
json=sanitized,
|
||||||
user_service = getattr(self.ap, 'user_service', None)
|
headers={'X-LangBot-Telemetry-Token': telemetry_token},
|
||||||
if workspace_uuid and user_service is not None:
|
)
|
||||||
try:
|
|
||||||
owner = await user_service.get_workspace_owner(workspace_uuid)
|
|
||||||
owner_email = str(getattr(owner, 'user', '') or '').strip()
|
|
||||||
space_service = getattr(self.ap, 'space_service', None)
|
|
||||||
access_token = (
|
|
||||||
await space_service.get_valid_access_token(owner_email)
|
|
||||||
if owner_email and space_service is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
access_token = str(access_token or '').strip()
|
|
||||||
if access_token:
|
|
||||||
headers['Authorization'] = f'Bearer {access_token}'
|
|
||||||
except Exception:
|
|
||||||
self.ap.logger.debug(
|
|
||||||
'Could not resolve authenticated telemetry reporter', exc_info=True
|
|
||||||
)
|
|
||||||
if headers:
|
|
||||||
request = client.post(url, json=sanitized, headers=headers)
|
|
||||||
else:
|
else:
|
||||||
request = client.post(url, json=sanitized)
|
request = client.post(url, json=sanitized)
|
||||||
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class VectorDBManager:
|
|||||||
use_business_database = pgvector_config.get('use_business_database', False)
|
use_business_database = pgvector_config.get('use_business_database', False)
|
||||||
allowed_dimensions = pgvector_config.get(
|
allowed_dimensions = pgvector_config.get(
|
||||||
'allowed_dimensions',
|
'allowed_dimensions',
|
||||||
[384, 512, 768, 1024, 1536, 3072],
|
[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
common_options = {
|
common_options = {
|
||||||
'use_business_database': use_business_database,
|
'use_business_database': use_business_database,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import AsyncIterator
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
@@ -18,7 +18,7 @@ from langbot.pkg.vector.vdb import VectorDatabase
|
|||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
|
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||||
|
|
||||||
# pgvector schema only stores these metadata fields.
|
# pgvector schema only stores these metadata fields.
|
||||||
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
||||||
@@ -321,12 +321,7 @@ class PgVectorDatabase(VectorDatabase):
|
|||||||
if len(query_embedding) != scope.embedding_dimension:
|
if len(query_embedding) != scope.embedding_dimension:
|
||||||
raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
|
raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
|
||||||
|
|
||||||
typed_embedding = sqlalchemy.cast(
|
typed_embedding = sqlalchemy.cast(PgVectorEntry.embedding, Vector(scope.embedding_dimension))
|
||||||
PgVectorEntry.embedding,
|
|
||||||
HALFVEC(scope.embedding_dimension)
|
|
||||||
if scope.embedding_dimension > 2000
|
|
||||||
else Vector(scope.embedding_dimension),
|
|
||||||
)
|
|
||||||
distance = typed_embedding.cosine_distance(query_embedding)
|
distance = typed_embedding.cosine_distance(query_embedding)
|
||||||
statement = (
|
statement = (
|
||||||
sqlalchemy.select(
|
sqlalchemy.select(
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ class ResolvedWorkspaceAccess:
|
|||||||
@dataclasses.dataclass(frozen=True, slots=True)
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
class WorkspaceMemberView:
|
class WorkspaceMemberView:
|
||||||
membership: WorkspaceMembership
|
membership: WorkspaceMembership
|
||||||
display_name: str
|
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
|
|
||||||
@@ -295,7 +294,7 @@ class WorkspaceCollaborationService:
|
|||||||
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
|
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
|
||||||
await self._load_actor(active_session, workspace_uuid, actor)
|
await self._load_actor(active_session, workspace_uuid, actor)
|
||||||
statement = (
|
statement = (
|
||||||
sqlalchemy.select(WorkspaceMembership, User.user, User.normalized_email)
|
sqlalchemy.select(WorkspaceMembership, User.user)
|
||||||
.join(User, User.uuid == WorkspaceMembership.account_uuid)
|
.join(User, User.uuid == WorkspaceMembership.account_uuid)
|
||||||
.where(
|
.where(
|
||||||
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
||||||
@@ -305,12 +304,8 @@ class WorkspaceCollaborationService:
|
|||||||
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
|
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
WorkspaceMemberView(
|
WorkspaceMemberView(membership=membership, email=email)
|
||||||
membership=membership,
|
for membership, email in (await active_session.execute(statement)).all()
|
||||||
display_name=display_name,
|
|
||||||
email=email,
|
|
||||||
)
|
|
||||||
for membership, display_name, email in (await active_session.execute(statement)).all()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return await self._run(operation, session=session, read_only=True)
|
return await self._run(operation, session=session, read_only=True)
|
||||||
@@ -611,8 +606,6 @@ class WorkspaceCollaborationService:
|
|||||||
) -> WorkspaceMembership:
|
) -> WorkspaceMembership:
|
||||||
if role not in {item.value for item in MembershipRole}:
|
if role not in {item.value for item in MembershipRole}:
|
||||||
raise MembershipPermissionError('Unknown Workspace role')
|
raise MembershipPermissionError('Unknown Workspace role')
|
||||||
if role == MembershipRole.OWNER.value:
|
|
||||||
raise MembershipPermissionError('Workspace ownership cannot be transferred')
|
|
||||||
|
|
||||||
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
||||||
await self._require_active_workspace(active_session, workspace_uuid)
|
await self._require_active_workspace(active_session, workspace_uuid)
|
||||||
@@ -624,8 +617,8 @@ class WorkspaceCollaborationService:
|
|||||||
target_account_uuid,
|
target_account_uuid,
|
||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
||||||
if target.role == MembershipRole.OWNER.value:
|
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
|
||||||
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
||||||
target.role = role
|
target.role = role
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -651,7 +644,7 @@ class WorkspaceCollaborationService:
|
|||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target)
|
self._require_can_manage_target(persisted_actor, target)
|
||||||
if target.role == MembershipRole.OWNER.value:
|
if target.role == MembershipRole.OWNER.value:
|
||||||
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
||||||
target.status = MembershipStatus.REMOVED.value
|
target.status = MembershipStatus.REMOVED.value
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -758,6 +751,26 @@ class WorkspaceCollaborationService:
|
|||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
return persisted_actor
|
return persisted_actor
|
||||||
|
|
||||||
|
async def _require_another_owner(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_uuid: str,
|
||||||
|
excluded_account_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
owners = (
|
||||||
|
await session.scalars(
|
||||||
|
sqlalchemy.select(WorkspaceMembership)
|
||||||
|
.where(
|
||||||
|
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
||||||
|
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
|
||||||
|
WorkspaceMembership.role == MembershipRole.OWNER.value,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
|
||||||
|
raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
|
||||||
|
|
||||||
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
||||||
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -13,4 +12,3 @@ class WorkspaceExecutionBinding:
|
|||||||
placement_generation: int
|
placement_generation: int
|
||||||
write_fenced: bool
|
write_fenced: bool
|
||||||
state: str
|
state: str
|
||||||
workspace_created_at: datetime.datetime | None = None
|
|
||||||
|
|||||||
@@ -283,7 +283,6 @@ class WorkspaceService:
|
|||||||
placement_generation=execution_state.active_generation,
|
placement_generation=execution_state.active_generation,
|
||||||
write_fenced=execution_state.write_fenced,
|
write_fenced=execution_state.write_fenced,
|
||||||
state=execution_state.state,
|
state=execution_state.state,
|
||||||
workspace_created_at=workspace.created_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
binding = await self._run(operation, session=session)
|
binding = await self._run(operation, session=session)
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ vdb:
|
|||||||
# keep this false when deliberately using an external pgvector DB.
|
# keep this false when deliberately using an external pgvector DB.
|
||||||
use_business_database: false
|
use_business_database: false
|
||||||
# Release migrations create one partial ANN index per enabled value.
|
# Release migrations create one partial ANN index per enabled value.
|
||||||
allowed_dimensions: [384, 512, 768, 1024, 1536, 3072]
|
allowed_dimensions: [384, 512, 768, 1024, 1536]
|
||||||
host: '127.0.0.1'
|
host: '127.0.0.1'
|
||||||
port: 5433
|
port: 5433
|
||||||
database: 'langbot'
|
database: 'langbot'
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ stages:
|
|||||||
default:
|
default:
|
||||||
primary: ''
|
primary: ''
|
||||||
fallbacks: []
|
fallbacks: []
|
||||||
|
reasoning: {}
|
||||||
- name: max-round
|
- name: max-round
|
||||||
label:
|
label:
|
||||||
en_US: Max Round
|
en_US: Max Round
|
||||||
|
|||||||
@@ -106,12 +106,7 @@ async def plugin_security_api(plugin_module):
|
|||||||
application.plugin_connector.require_workspace_context = AsyncMock()
|
application.plugin_connector.require_workspace_context = AsyncMock()
|
||||||
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
|
||||||
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
|
||||||
application.plugin_connector.get_debug_info = AsyncMock(
|
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
|
||||||
return_value={
|
|
||||||
'plugin_debug_key': 'runtime-debug-secret',
|
|
||||||
'expires_at': '2026-08-04T12:00:00Z',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
|
||||||
application.plugin_connector.set_plugin_config = AsyncMock()
|
application.plugin_connector.set_plugin_config = AsyncMock()
|
||||||
|
|
||||||
@@ -237,9 +232,8 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
|||||||
assert (await allowed.get_json())['data'] == {
|
assert (await allowed.get_json())['data'] == {
|
||||||
'debug_url': 'http://localhost:5401',
|
'debug_url': 'http://localhost:5401',
|
||||||
'plugin_debug_key': 'runtime-debug-secret',
|
'plugin_debug_key': 'runtime-debug-secret',
|
||||||
'expires_at': '2026-08-04T12:00:00Z',
|
|
||||||
}
|
}
|
||||||
application.plugin_connector.get_debug_info.assert_awaited_once()
|
application.plugin_connector.get_debug_info.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||||
|
|
||||||
@@ -306,34 +304,12 @@ class TestUserInitEndpoint:
|
|||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': False,
|
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
||||||
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud(
|
|
||||||
self, quart_test_client, fake_api_app
|
|
||||||
):
|
|
||||||
fake_api_app.deployment = SimpleNamespace(mode='cloud')
|
|
||||||
fake_api_app.user_service.is_initialized.return_value = True
|
|
||||||
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
|
||||||
return_value={'password_login_enabled': True, 'space_login_enabled': True}
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await quart_test_client.get('/api/v1/user/account-info')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = await response.get_json()
|
|
||||||
assert data['data'] == {
|
|
||||||
'initialized': True,
|
|
||||||
'authenticated_invitation_acceptance_enabled': True,
|
|
||||||
'password_login_enabled': False,
|
|
||||||
'space_login_enabled': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
||||||
fake_api_app.user_service.is_initialized.return_value = True
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
|
|||||||
assert Permission.RESOURCE_MANAGE.value in permissions
|
assert Permission.RESOURCE_MANAGE.value in permissions
|
||||||
assert not permissions.intersection(
|
assert not permissions.intersection(
|
||||||
{
|
{
|
||||||
|
Permission.OWNER_TRANSFER.value,
|
||||||
Permission.MEMBER_VIEW.value,
|
Permission.MEMBER_VIEW.value,
|
||||||
Permission.MEMBER_INVITE.value,
|
Permission.MEMBER_INVITE.value,
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
Permission.MEMBER_UPDATE_ROLE.value,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
@@ -15,7 +14,6 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
|||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||||
WORKSPACE_CREATED_AT = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -60,12 +58,6 @@ async def space_oauth_api():
|
|||||||
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
|
||||||
)
|
)
|
||||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||||
application.workspace_service.get_execution_binding = AsyncMock(
|
|
||||||
return_value=SimpleNamespace(
|
|
||||||
workspace_uuid=WORKSPACE_UUID,
|
|
||||||
workspace_created_at=WORKSPACE_CREATED_AT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
application.space_service.get_oauth_authorize_url = Mock(
|
application.space_service.get_oauth_authorize_url = Mock(
|
||||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||||
)
|
)
|
||||||
@@ -242,11 +234,7 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
|
||||||
'oauth-code',
|
|
||||||
[WORKSPACE_UUID],
|
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -284,10 +272,9 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
'/api/v1/user/space-credits',
|
'/api/v1/user/space-credits',
|
||||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
)
|
)
|
||||||
payload = await response.get_json()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert payload['data'] == {
|
assert (await response.get_json())['data'] == {
|
||||||
'credits': 25000,
|
'credits': 25000,
|
||||||
'owner_space_bound': True,
|
'owner_space_bound': True,
|
||||||
'is_workspace_owner': True,
|
'is_workspace_owner': True,
|
||||||
@@ -295,31 +282,6 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
|
||||||
application, client = space_oauth_api
|
|
||||||
application.deployment.mode = 'cloud'
|
|
||||||
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
|
|
||||||
application.space_service.get_credits = AsyncMock()
|
|
||||||
application.cloud_model_catalog_service = SimpleNamespace(
|
|
||||||
get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
'/api/v1/user/space-credits',
|
|
||||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
|
||||||
)
|
|
||||||
payload = await response.get_json()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert payload['data'] == {
|
|
||||||
'credits': 25000,
|
|
||||||
'owner_space_bound': True,
|
|
||||||
'is_workspace_owner': True,
|
|
||||||
}
|
|
||||||
application.space_service.get_credits.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
|
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
workspace_uuid = current['workspace']['uuid']
|
workspace_uuid = current['workspace']['uuid']
|
||||||
assert current['membership']['role'] == 'owner'
|
assert current['membership']['role'] == 'owner'
|
||||||
assert 'member.invite' in current['permissions']
|
assert 'member.invite' in current['permissions']
|
||||||
assert 'owner.transfer' not in current['permissions']
|
|
||||||
|
|
||||||
invite_response = await client.post(
|
invite_response = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
@@ -264,14 +263,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert member_current['membership']['role'] == 'viewer'
|
assert member_current['membership']['role'] == 'viewer'
|
||||||
assert 'member.invite' not in member_current['permissions']
|
assert 'member.invite' not in member_current['permissions']
|
||||||
|
|
||||||
transfer_response = await client.patch(
|
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}',
|
|
||||||
headers=_auth(owner_token, workspace_uuid),
|
|
||||||
json={'role': 'owner'},
|
|
||||||
)
|
|
||||||
assert transfer_response.status_code == 403
|
|
||||||
assert (await transfer_response.get_json())['code'] == 'permission_denied'
|
|
||||||
|
|
||||||
forbidden_invite = await client.post(
|
forbidden_invite = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
headers=_auth(member_token, workspace_uuid),
|
headers=_auth(member_token, workspace_uuid),
|
||||||
@@ -281,31 +272,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
|
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_member_list_returns_display_name_and_email(workspace_api):
|
|
||||||
_, client, engine, owner_token = workspace_api
|
|
||||||
|
|
||||||
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
|
||||||
current = (await current_response.get_json())['data']
|
|
||||||
workspace_uuid = current['workspace']['uuid']
|
|
||||||
owner_uuid = current['membership']['account_uuid']
|
|
||||||
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.update(User).where(User.uuid == owner_uuid).values(user='Owner Display Name')
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/members',
|
|
||||||
headers=_auth(owner_token, workspace_uuid),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
members = (await response.get_json())['data']['members']
|
|
||||||
assert len(members) == 1
|
|
||||||
assert members[0]['display_name'] == 'Owner Display Name'
|
|
||||||
assert members[0]['email'] == 'owner@example.com'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
|
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
|
||||||
_, client, _, owner_token = workspace_api
|
_, client, _, owner_token = workspace_api
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
@@ -95,18 +98,6 @@ class TestSQLiteMigrationBaseline:
|
|||||||
class TestSQLiteMigrationUpgrade:
|
class TestSQLiteMigrationUpgrade:
|
||||||
"""Tests for upgrade to head workflow."""
|
"""Tests for upgrade to head workflow."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine):
|
|
||||||
"""A database released at the production-only 0016 head must remain upgradable."""
|
|
||||||
async with sqlite_engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay')
|
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
|
||||||
assert _get_script_head() == '001a_pgvector_dimension_3072'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
"""
|
"""
|
||||||
@@ -202,6 +193,66 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reasoning_config_migrates_existing_models(self, sqlite_engine):
|
||||||
|
"""Upgrade from 0017 backfills reasoning config and keeps a database default."""
|
||||||
|
async with sqlite_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE llm_models (
|
||||||
|
uuid VARCHAR(255) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
provider_uuid VARCHAR(255) NOT NULL,
|
||||||
|
abilities JSON NOT NULL,
|
||||||
|
context_length INTEGER,
|
||||||
|
extra_args JSON NOT NULL,
|
||||||
|
prefered_ranking INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO llm_models (
|
||||||
|
uuid, name, provider_uuid, abilities, extra_args, prefered_ranking
|
||||||
|
) VALUES (
|
||||||
|
'existing-model', 'Existing Model', 'provider', '[]', '{}', 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await run_alembic_stamp(sqlite_engine, '0017_oss_workspace_identity')
|
||||||
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
|
async with sqlite_engine.begin() as conn:
|
||||||
|
columns = await conn.run_sync(lambda sync_conn: sqlalchemy.inspect(sync_conn).get_columns('llm_models'))
|
||||||
|
reasoning_column = next(column for column in columns if column['name'] == 'reasoning_config')
|
||||||
|
assert reasoning_column['nullable'] is False
|
||||||
|
|
||||||
|
existing_value = (
|
||||||
|
await conn.execute(text("SELECT reasoning_config FROM llm_models WHERE uuid = 'existing-model'"))
|
||||||
|
).scalar_one()
|
||||||
|
assert json.loads(existing_value) == {'level': 'provider_default'}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO llm_models (
|
||||||
|
uuid, name, provider_uuid, abilities, extra_args, prefered_ranking
|
||||||
|
) VALUES (
|
||||||
|
'new-model', 'New Model', 'provider', '[]', '{}', 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_value = (
|
||||||
|
await conn.execute(text("SELECT reasoning_config FROM llm_models WHERE uuid = 'new-model'"))
|
||||||
|
).scalar_one()
|
||||||
|
assert json.loads(new_value) == {'level': 'provider_default'}
|
||||||
|
|
||||||
|
|
||||||
class TestSQLiteMigrationFreshDatabase:
|
class TestSQLiteMigrationFreshDatabase:
|
||||||
"""Tests for fresh database workflow."""
|
"""Tests for fresh database workflow."""
|
||||||
|
|||||||
@@ -85,32 +85,6 @@ async def clean_database(postgres_engine: AsyncEngine):
|
|||||||
await clean()
|
await clean()
|
||||||
|
|
||||||
|
|
||||||
async def test_upgrade_adds_3072_dimension_index_and_constraint(
|
|
||||||
postgres_engine: AsyncEngine,
|
|
||||||
clean_database,
|
|
||||||
) -> None:
|
|
||||||
async with postgres_engine.begin() as conn:
|
|
||||||
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
await run_alembic_stamp(postgres_engine, '0010_scope_resources')
|
|
||||||
await run_alembic_upgrade(postgres_engine, 'head')
|
|
||||||
|
|
||||||
async with postgres_engine.connect() as conn:
|
|
||||||
constraint = await conn.scalar(
|
|
||||||
text(
|
|
||||||
'SELECT pg_get_constraintdef(oid) FROM pg_constraint '
|
|
||||||
"WHERE conrelid = 'langbot_vectors'::regclass "
|
|
||||||
"AND conname = 'ck_langbot_vectors_embedding_dimension_enabled'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert '3072' in constraint
|
|
||||||
index_definition = await conn.scalar(
|
|
||||||
text("SELECT indexdef FROM pg_indexes WHERE indexname = 'ix_langbot_vectors_hnsw_cosine_3072'")
|
|
||||||
)
|
|
||||||
assert 'halfvec(3072)' in index_definition
|
|
||||||
assert 'halfvec_cosine_ops' in index_definition
|
|
||||||
|
|
||||||
|
|
||||||
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
|
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
|
||||||
postgres_url: str,
|
postgres_url: str,
|
||||||
postgres_engine: AsyncEngine,
|
postgres_engine: AsyncEngine,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_
|
|||||||
'use': 'pgvector',
|
'use': 'pgvector',
|
||||||
'pgvector': {
|
'pgvector': {
|
||||||
'use_business_database': True,
|
'use_business_database': True,
|
||||||
'allowed_dimensions': [384, 512, 768, 1024, 1536, 3072],
|
'allowed_dimensions': [384, 512, 768, 1024, 1536],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.user import User
|
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}')
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner'))
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
workspace_uuid = '00000000-0000-4000-8000-000000000001'
|
|
||||||
creator_uuid = '00000000-0000-4000-8000-000000000010'
|
|
||||||
promoted_uuid = '00000000-0000-4000-8000-000000000020'
|
|
||||||
async with session_factory() as session:
|
|
||||||
session.add_all(
|
|
||||||
[
|
|
||||||
User(
|
|
||||||
uuid=creator_uuid,
|
|
||||||
user='creator@example.test',
|
|
||||||
normalized_email='creator@example.test',
|
|
||||||
password='hash',
|
|
||||||
account_type='local',
|
|
||||||
),
|
|
||||||
User(
|
|
||||||
uuid=promoted_uuid,
|
|
||||||
user='promoted@example.test',
|
|
||||||
normalized_email='promoted@example.test',
|
|
||||||
password='hash',
|
|
||||||
account_type='local',
|
|
||||||
),
|
|
||||||
Workspace(
|
|
||||||
uuid=workspace_uuid,
|
|
||||||
instance_uuid='instance-test',
|
|
||||||
name='Workspace',
|
|
||||||
slug='workspace',
|
|
||||||
type='team',
|
|
||||||
status='active',
|
|
||||||
source='local',
|
|
||||||
created_by_account_uuid=creator_uuid,
|
|
||||||
),
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='00000000-0000-4000-8000-000000000100',
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
account_uuid=creator_uuid,
|
|
||||||
role='owner',
|
|
||||||
status='active',
|
|
||||||
),
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='00000000-0000-4000-8000-000000000200',
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
account_uuid=promoted_uuid,
|
|
||||||
role='owner',
|
|
||||||
status='active',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await run_alembic_stamp(engine, '0018_merge_launch_replay')
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
roles = dict(
|
|
||||||
(
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
'SELECT account_uuid, role FROM workspace_memberships '
|
|
||||||
'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid'
|
|
||||||
),
|
|
||||||
{'workspace_uuid': workspace_uuid},
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
)
|
|
||||||
assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'}
|
|
||||||
indexes = await connection.run_sync(
|
|
||||||
lambda sync_connection: {
|
|
||||||
index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert 'uq_workspace_memberships_one_active_owner' in indexes
|
|
||||||
|
|
||||||
with pytest.raises(sa.exc.IntegrityError):
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"),
|
|
||||||
{'account_uuid': promoted_uuid},
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -27,9 +27,10 @@ def test_owner_has_every_fixed_permission():
|
|||||||
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
|
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_delete_workspace_or_link_billing():
|
def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing():
|
||||||
ctx = _context(authz.WorkspaceRole.ADMIN)
|
ctx = _context(authz.WorkspaceRole.ADMIN)
|
||||||
|
|
||||||
|
assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER)
|
||||||
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
|
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
|
||||||
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
||||||
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Cloud Runtime write protection for the managed LangBot Models catalog."""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from langbot.pkg.api.http.service import model as model_service_module
|
|
||||||
from langbot.pkg.api.http.service.model import (
|
|
||||||
EmbeddingModelsService,
|
|
||||||
LLMModelsService,
|
|
||||||
RerankModelsService,
|
|
||||||
_assert_cloud_managed_provider_mutable,
|
|
||||||
)
|
|
||||||
from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
|
|
||||||
|
|
||||||
WORKSPACE = 'workspace-a'
|
|
||||||
PROVIDER = 'managed-provider'
|
|
||||||
MODEL = 'managed-model'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None:
|
|
||||||
async def managed_provider(_ap, _context, provider_uuid):
|
|
||||||
assert provider_uuid == PROVIDER
|
|
||||||
return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}
|
|
||||||
|
|
||||||
monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider)
|
|
||||||
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await _assert_cloud_managed_provider_mutable(
|
|
||||||
application,
|
|
||||||
WORKSPACE,
|
|
||||||
PROVIDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
application.persistence_mgr.mode.value = 'normal'
|
|
||||||
await _assert_cloud_managed_provider_mutable(
|
|
||||||
application,
|
|
||||||
WORKSPACE,
|
|
||||||
PROVIDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('service_type', 'create_method', 'model_data'),
|
|
||||||
[
|
|
||||||
(LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}),
|
|
||||||
(EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}),
|
|
||||||
(RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_model_types_reject_creation_under_managed_provider(
|
|
||||||
monkeypatch,
|
|
||||||
service_type,
|
|
||||||
create_method: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
|
||||||
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
|
||||||
application = SimpleNamespace(
|
|
||||||
persistence_mgr=SimpleNamespace(),
|
|
||||||
provider_service=SimpleNamespace(
|
|
||||||
get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER})
|
|
||||||
),
|
|
||||||
model_mgr=None,
|
|
||||||
)
|
|
||||||
service = service_type(application)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await getattr(service, create_method)(WORKSPACE, model_data)
|
|
||||||
|
|
||||||
guard.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('service_type', 'get_method', 'write_method', 'payload'),
|
|
||||||
[
|
|
||||||
(LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}),
|
|
||||||
(LLMModelsService, 'get_llm_model', 'delete_llm_model', None),
|
|
||||||
(EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}),
|
|
||||||
(EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None),
|
|
||||||
(RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}),
|
|
||||||
(RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_model_types_reject_update_and_delete_for_managed_provider(
|
|
||||||
monkeypatch,
|
|
||||||
service_type,
|
|
||||||
get_method: str,
|
|
||||||
write_method: str,
|
|
||||||
payload: dict | None,
|
|
||||||
) -> None:
|
|
||||||
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
|
||||||
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
|
||||||
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
|
||||||
service = service_type(application)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
service,
|
|
||||||
get_method,
|
|
||||||
AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload)
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await getattr(service, write_method)(*args)
|
|
||||||
|
|
||||||
guard.assert_awaited_once()
|
|
||||||
@@ -17,12 +17,14 @@ import pytest
|
|||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
from langbot.pkg.api.http.service.model import (
|
from langbot.pkg.api.http.service.model import (
|
||||||
LLMModelsService,
|
LLMModelsService,
|
||||||
EmbeddingModelsService,
|
EmbeddingModelsService,
|
||||||
RerankModelsService,
|
RerankModelsService,
|
||||||
_parse_provider_api_keys,
|
_parse_provider_api_keys,
|
||||||
_runtime_model_data,
|
_runtime_model_data,
|
||||||
|
_serialize_llm_model,
|
||||||
_validate_provider_supports,
|
_validate_provider_supports,
|
||||||
)
|
)
|
||||||
from langbot.pkg.api.http.service import model as model_service_module
|
from langbot.pkg.api.http.service import model as model_service_module
|
||||||
@@ -64,15 +66,19 @@ def _create_mock_llm_model(
|
|||||||
abilities: list = None,
|
abilities: list = None,
|
||||||
context_length: int | None = None,
|
context_length: int | None = None,
|
||||||
extra_args: dict = None,
|
extra_args: dict = None,
|
||||||
|
reasoning_config: dict = None,
|
||||||
) -> Mock:
|
) -> Mock:
|
||||||
"""Helper to create mock LLMModel entity."""
|
"""Helper to create mock LLMModel entity."""
|
||||||
model = Mock(spec=LLMModel)
|
model = Mock(spec=LLMModel)
|
||||||
|
model.workspace_uuid = WORKSPACE_UUID
|
||||||
model.uuid = model_uuid
|
model.uuid = model_uuid
|
||||||
model.name = name
|
model.name = name
|
||||||
model.provider_uuid = provider_uuid
|
model.provider_uuid = provider_uuid
|
||||||
model.abilities = abilities or []
|
model.abilities = abilities or []
|
||||||
model.context_length = context_length
|
model.context_length = context_length
|
||||||
model.extra_args = extra_args or {}
|
model.extra_args = extra_args or {}
|
||||||
|
model.reasoning_config = reasoning_config or {'level': 'provider_default'}
|
||||||
|
model.prefered_ranking = 0
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +162,26 @@ def _create_runtime_model_mgr() -> SimpleNamespace:
|
|||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def _create_reasoning_runtime_provider(capabilities: dict) -> SimpleNamespace:
|
||||||
|
execution_context = ExecutionContext(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
|
placement_generation=1,
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
execution_context=execution_context,
|
||||||
|
provider_entity=ModelProvider(
|
||||||
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
|
uuid='provider-uuid',
|
||||||
|
name='Reasoning Provider',
|
||||||
|
requester='openai',
|
||||||
|
base_url='https://api.openai.com',
|
||||||
|
api_keys=[],
|
||||||
|
),
|
||||||
|
requester=SimpleNamespace(get_reasoning_capabilities=Mock(return_value=capabilities)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestParseProviderApiKeys:
|
class TestParseProviderApiKeys:
|
||||||
"""Tests for _parse_provider_api_keys helper function."""
|
"""Tests for _parse_provider_api_keys helper function."""
|
||||||
|
|
||||||
@@ -209,6 +235,42 @@ class TestRuntimeModelData:
|
|||||||
assert result['extra_args'] == {'temp': 0.7}
|
assert result['extra_args'] == {'temp': 0.7}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerializeLLMModel:
|
||||||
|
def test_includes_runtime_reasoning_capabilities(self):
|
||||||
|
model = _create_mock_llm_model(
|
||||||
|
abilities=['reasoning'],
|
||||||
|
reasoning_config={'level': 'high'},
|
||||||
|
)
|
||||||
|
capabilities = {
|
||||||
|
'supported': True,
|
||||||
|
'levels': ['provider_default', 'low', 'high'],
|
||||||
|
'source': 'litellm',
|
||||||
|
}
|
||||||
|
runtime_model = SimpleNamespace(
|
||||||
|
model_entity=model,
|
||||||
|
provider=SimpleNamespace(
|
||||||
|
requester=SimpleNamespace(get_reasoning_capabilities=Mock(return_value=capabilities))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
persistence_mgr=SimpleNamespace(
|
||||||
|
serialize_model=Mock(
|
||||||
|
return_value={
|
||||||
|
'uuid': model.uuid,
|
||||||
|
'name': model.name,
|
||||||
|
'reasoning_config': {'level': 'high'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
model_mgr=SimpleNamespace(llm_model_dict={('workspace', model.uuid): runtime_model}),
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = _serialize_llm_model(ap, model)
|
||||||
|
|
||||||
|
assert serialized['reasoning_config'] == {'level': 'high'}
|
||||||
|
assert serialized['reasoning_capabilities'] == capabilities
|
||||||
|
|
||||||
|
|
||||||
class TestLLMModelsServiceGetLLMModels:
|
class TestLLMModelsServiceGetLLMModels:
|
||||||
"""Tests for LLMModelsService.get_llm_models method."""
|
"""Tests for LLMModelsService.get_llm_models method."""
|
||||||
|
|
||||||
@@ -580,6 +642,66 @@ class TestLLMModelsServiceCreateLLMModel:
|
|||||||
ap.provider_service.find_or_create_provider.assert_called_once()
|
ap.provider_service.find_or_create_provider.assert_called_once()
|
||||||
assert result_uuid is not None
|
assert result_uuid is not None
|
||||||
|
|
||||||
|
async def test_create_llm_model_validates_explicit_reasoning_level(self):
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=_create_mock_result([])))
|
||||||
|
runtime_provider = _create_reasoning_runtime_provider(
|
||||||
|
{
|
||||||
|
'supported': True,
|
||||||
|
'levels': ['provider_default', 'low', 'high'],
|
||||||
|
'source': 'litellm',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ap.model_mgr = _create_runtime_model_mgr()
|
||||||
|
ap.model_mgr.provider_dict = {'provider-uuid': runtime_provider}
|
||||||
|
|
||||||
|
service = LLMModelsService(ap)
|
||||||
|
await service.create_llm_model(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
{
|
||||||
|
'uuid': 'reasoning-model',
|
||||||
|
'name': 'Reasoning Model',
|
||||||
|
'provider_uuid': 'provider-uuid',
|
||||||
|
'abilities': ['reasoning'],
|
||||||
|
'reasoning_config': {'level': 'high'},
|
||||||
|
'extra_args': {},
|
||||||
|
},
|
||||||
|
preserve_uuid=True,
|
||||||
|
auto_set_to_default_pipeline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[1]
|
||||||
|
assert runtime_entity.reasoning_config == {'level': 'high'}
|
||||||
|
|
||||||
|
async def test_create_llm_model_rejects_unsupported_reasoning_before_insert(self):
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||||
|
runtime_provider = _create_reasoning_runtime_provider(
|
||||||
|
{
|
||||||
|
'supported': True,
|
||||||
|
'levels': ['provider_default'],
|
||||||
|
'source': 'manual',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ap.model_mgr = _create_runtime_model_mgr()
|
||||||
|
ap.model_mgr.provider_dict = {'provider-uuid': runtime_provider}
|
||||||
|
|
||||||
|
service = LLMModelsService(ap)
|
||||||
|
with pytest.raises(ValueError, match='Available levels: provider_default'):
|
||||||
|
await service.create_llm_model(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
{
|
||||||
|
'name': 'Unknown Reasoning Model',
|
||||||
|
'provider_uuid': 'provider-uuid',
|
||||||
|
'abilities': ['reasoning'],
|
||||||
|
'reasoning_config': {'level': 'high'},
|
||||||
|
'extra_args': {},
|
||||||
|
},
|
||||||
|
auto_set_to_default_pipeline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
class TestLLMModelsServiceUpdateLLMModel:
|
class TestLLMModelsServiceUpdateLLMModel:
|
||||||
"""Tests for LLMModelsService.update_llm_model method."""
|
"""Tests for LLMModelsService.update_llm_model method."""
|
||||||
@@ -595,7 +717,10 @@ class TestLLMModelsServiceUpdateLLMModel:
|
|||||||
ap.model_mgr.remove_llm_model = AsyncMock()
|
ap.model_mgr.remove_llm_model = AsyncMock()
|
||||||
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
|
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock()
|
existing_model = _create_mock_llm_model()
|
||||||
|
ap.persistence_mgr.execute_async = AsyncMock(
|
||||||
|
side_effect=[_create_mock_result(first_item=existing_model), _create_mock_result()]
|
||||||
|
)
|
||||||
|
|
||||||
service = LLMModelsService(ap)
|
service = LLMModelsService(ap)
|
||||||
service.get_llm_model = AsyncMock(return_value=_existing_llm_data())
|
service.get_llm_model = AsyncMock(return_value=_existing_llm_data())
|
||||||
@@ -623,7 +748,8 @@ class TestLLMModelsServiceUpdateLLMModel:
|
|||||||
ap.model_mgr.provider_dict = {} # Empty
|
ap.model_mgr.provider_dict = {} # Empty
|
||||||
ap.model_mgr.remove_llm_model = AsyncMock()
|
ap.model_mgr.remove_llm_model = AsyncMock()
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock()
|
existing_model = _create_mock_llm_model()
|
||||||
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_model))
|
||||||
|
|
||||||
service = LLMModelsService(ap)
|
service = LLMModelsService(ap)
|
||||||
service.get_llm_model = AsyncMock(return_value=_existing_llm_data('nonexistent-provider'))
|
service.get_llm_model = AsyncMock(return_value=_existing_llm_data('nonexistent-provider'))
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
|||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
WORKSPACE_UUID = 'workspace-a'
|
WORKSPACE_UUID = 'workspace-a'
|
||||||
SYSTEM_REQUESTER = 'space-chat-completions'
|
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_provider(
|
def _create_mock_provider(
|
||||||
@@ -1006,56 +1005,3 @@ class TestProviderSecretRoundtrip:
|
|||||||
)
|
)
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
class TestCloudManagedProviderProtection:
|
|
||||||
@staticmethod
|
|
||||||
def _service() -> ModelProviderService:
|
|
||||||
ap = SimpleNamespace(
|
|
||||||
persistence_mgr=SimpleNamespace(
|
|
||||||
mode=SimpleNamespace(value='cloud_runtime'),
|
|
||||||
execute_async=AsyncMock(),
|
|
||||||
),
|
|
||||||
model_mgr=SimpleNamespace(),
|
|
||||||
)
|
|
||||||
return ModelProviderService(ap)
|
|
||||||
|
|
||||||
async def test_cloud_rejects_user_created_system_requester(self):
|
|
||||||
service = self._service()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='reserved'):
|
|
||||||
await service.create_provider(
|
|
||||||
WORKSPACE_UUID,
|
|
||||||
{
|
|
||||||
'name': 'Fake LangBot Models',
|
|
||||||
'requester': SYSTEM_REQUESTER,
|
|
||||||
'base_url': 'https://example.invalid/v1',
|
|
||||||
'api_keys': ['fake'],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='reserved'):
|
|
||||||
await service.find_or_create_provider(
|
|
||||||
WORKSPACE_UUID,
|
|
||||||
SYSTEM_REQUESTER,
|
|
||||||
'https://api.langbot.cloud/v1',
|
|
||||||
['fake'],
|
|
||||||
)
|
|
||||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
|
||||||
service = self._service()
|
|
||||||
service.get_provider = AsyncMock(
|
|
||||||
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
|
||||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_oss_does_not_reserve_space_requester(self):
|
|
||||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
|
||||||
service = ModelProviderService(ap)
|
|
||||||
assert service._system_requester_is_reserved(SYSTEM_REQUESTER) is False
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import time
|
|||||||
|
|
||||||
from langbot.pkg.api.http.service.space import SpaceService
|
from langbot.pkg.api.http.service.space import SpaceService
|
||||||
from langbot.pkg.entity.persistence.user import User
|
from langbot.pkg.entity.persistence.user import User
|
||||||
from langbot.pkg.utils import constants
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -574,20 +573,10 @@ class TestSpaceServiceExchangeOAuthCode:
|
|||||||
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
result = await service.exchange_oauth_code(
|
result = await service.exchange_oauth_code('auth_code')
|
||||||
'auth_code',
|
|
||||||
['workspace-1'],
|
|
||||||
{'workspace-1': 1_700_000_000},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert result['access_token'] == 'new_access_token'
|
assert result['access_token'] == 'new_access_token'
|
||||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
|
||||||
'code': 'auth_code',
|
|
||||||
'instance_id': constants.instance_id,
|
|
||||||
'workspace_uuids': ['workspace-1'],
|
|
||||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def test_exchange_oauth_code_api_error(self):
|
async def test_exchange_oauth_code_api_error(self):
|
||||||
"""Raises ValueError on API error."""
|
"""Raises ValueError on API error."""
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ class _Provider:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.manifest_provider = _Manifest()
|
self.manifest_provider = _Manifest()
|
||||||
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str):
|
|
||||||
del instance_uuid
|
|
||||||
raise AssertionError('not used by bootstrap contract tests')
|
|
||||||
|
|
||||||
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
||||||
del instance_config
|
del instance_config
|
||||||
return VerifiedCloudDeployment(
|
return VerifiedCloudDeployment(
|
||||||
@@ -83,7 +79,6 @@ class _Provider:
|
|||||||
entitlement_provider=_Entitlements(),
|
entitlement_provider=_Entitlements(),
|
||||||
directory_provider=_Directory(),
|
directory_provider=_Directory(),
|
||||||
manifest_provider=self.manifest_provider,
|
manifest_provider=self.manifest_provider,
|
||||||
model_catalog_provider=self,
|
|
||||||
verification_key_id='root-2026',
|
verification_key_id='root-2026',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +103,7 @@ def _cloud_config() -> dict:
|
|||||||
'use': 'pgvector',
|
'use': 'pgvector',
|
||||||
'pgvector': {
|
'pgvector': {
|
||||||
'use_business_database': True,
|
'use_business_database': True,
|
||||||
'allowed_dimensions': [384, 768, 1536, 3072],
|
'allowed_dimensions': [384, 768, 1536],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': False}},
|
'mcp': {'stdio': {'enabled': False}},
|
||||||
@@ -216,6 +211,7 @@ async def test_cloud_directory_capacity_contract_is_fail_closed(directory_config
|
|||||||
[
|
[
|
||||||
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
|
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
|
||||||
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
|
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
|
||||||
|
({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
|
||||||
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
|
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -232,23 +228,10 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_runtime_allows_explicitly_disabled_box():
|
|
||||||
config = _cloud_config()
|
|
||||||
config['box']['enabled'] = False
|
|
||||||
|
|
||||||
deployment = await resolve_deployment(
|
|
||||||
instance_uuid='instance-a',
|
|
||||||
instance_config=config,
|
|
||||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
|
||||||
now=1_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(deployment, VerifiedCloudDeployment)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
('mutate', 'message'),
|
('mutate', 'message'),
|
||||||
[
|
[
|
||||||
|
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
|
||||||
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
|
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
|
||||||
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -181,39 +181,6 @@ def _delta(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
|
|
||||||
application, _session_factory = projection_context
|
|
||||||
request_sync = Mock()
|
|
||||||
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
|
|
||||||
event = DirectoryEvent(
|
|
||||||
cursor=2,
|
|
||||||
uuid='20000000-0000-4000-8000-000000000002',
|
|
||||||
aggregate_uuid=WORKSPACE_UUID,
|
|
||||||
event_type='directory.changed',
|
|
||||||
revision=2,
|
|
||||||
payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
|
|
||||||
created_at=datetime.datetime(2026, 7, 24, 12, 30, tzinfo=datetime.UTC),
|
|
||||||
)
|
|
||||||
batch = DirectoryEventBatch(
|
|
||||||
instance_uuid=INSTANCE_UUID,
|
|
||||||
after_cursor=1,
|
|
||||||
cursor=2,
|
|
||||||
high_water_cursor=2,
|
|
||||||
events=[event],
|
|
||||||
)
|
|
||||||
service = DirectoryProjectionService(
|
|
||||||
application,
|
|
||||||
_Provider([_snapshot(1)], [batch], [_delta(workspaces=[_workspace(revision=2)])]),
|
|
||||||
INSTANCE_UUID,
|
|
||||||
)
|
|
||||||
await service.initialize()
|
|
||||||
request_sync.reset_mock()
|
|
||||||
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
request_sync.assert_called_once_with()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
reconcile_execution_projection = Mock()
|
reconcile_execution_projection = Mock()
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.cloud.model_catalog import (
|
|
||||||
CloudModelCatalogSnapshot,
|
|
||||||
CloudModelCatalogSyncService,
|
|
||||||
system_model_uuid,
|
|
||||||
system_provider_uuid,
|
|
||||||
)
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider
|
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
|
||||||
INSTANCE_UUID = 'instance-model-catalog'
|
|
||||||
WORKSPACE_A = '00000000-0000-4000-8000-000000000001'
|
|
||||||
WORKSPACE_B = '00000000-0000-4000-8000-000000000002'
|
|
||||||
OWNER_A = '10000000-0000-4000-8000-000000000001'
|
|
||||||
OWNER_B = '10000000-0000-4000-8000-000000000002'
|
|
||||||
|
|
||||||
|
|
||||||
class _CatalogProvider:
|
|
||||||
def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None:
|
|
||||||
self.snapshot = snapshot
|
|
||||||
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
assert instance_uuid == INSTANCE_UUID
|
|
||||||
return self.snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(
|
|
||||||
*,
|
|
||||||
key_a: str | None = 'owner-a-key',
|
|
||||||
model_id: str = 'gpt-test',
|
|
||||||
include_embedding: bool = True,
|
|
||||||
) -> CloudModelCatalogSnapshot:
|
|
||||||
models = [
|
|
||||||
{
|
|
||||||
'uuid': 'upstream-chat',
|
|
||||||
'model_id': model_id,
|
|
||||||
'category': 'chat',
|
|
||||||
'llm_abilities': ['chat', 'vision'],
|
|
||||||
'is_featured': True,
|
|
||||||
'featured_order': 7,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if include_embedding:
|
|
||||||
models.append(
|
|
||||||
{
|
|
||||||
'uuid': 'upstream-embedding',
|
|
||||||
'model_id': 'embedding-test',
|
|
||||||
'category': 'embedding',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return CloudModelCatalogSnapshot.model_validate(
|
|
||||||
{
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'generated_at': datetime.now(UTC),
|
|
||||||
'base_url': 'https://api.langbot.cloud/v1/',
|
|
||||||
'models': models,
|
|
||||||
'workspaces': [
|
|
||||||
{
|
|
||||||
'workspace_uuid': WORKSPACE_A,
|
|
||||||
'owner_account_uuid': OWNER_A,
|
|
||||||
'api_key': key_a,
|
|
||||||
'credits': 25000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workspace_uuid': WORKSPACE_B,
|
|
||||||
'owner_account_uuid': OWNER_B,
|
|
||||||
'api_key': 'owner-b-key',
|
|
||||||
'credits': 5000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None:
|
|
||||||
payload = _snapshot().model_dump(mode='json')
|
|
||||||
payload['models'][0]['llm_abilities'] = None
|
|
||||||
|
|
||||||
snapshot = CloudModelCatalogSnapshot.model_validate(payload)
|
|
||||||
|
|
||||||
assert snapshot.models[0].llm_abilities == ()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None:
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}')
|
|
||||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
||||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
||||||
bindings = [
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1),
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
|
||||||
]
|
|
||||||
workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings))
|
|
||||||
reload_counter = _AsyncCounter()
|
|
||||||
runtime_reload = SimpleNamespace(load_models_from_db=reload_counter)
|
|
||||||
app = SimpleNamespace(
|
|
||||||
persistence_mgr=manager,
|
|
||||||
workspace_service=workspace_service,
|
|
||||||
model_mgr=runtime_reload,
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
provider = _CatalogProvider(_snapshot())
|
|
||||||
service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(Workspace),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_A,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'A',
|
|
||||||
'slug': 'a',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_B,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'B',
|
|
||||||
'slug': 'b',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(ModelProvider).values(
|
|
||||||
uuid='custom-provider',
|
|
||||||
workspace_uuid=WORKSPACE_A,
|
|
||||||
name='Custom',
|
|
||||||
requester='openai-chat-completions',
|
|
||||||
base_url='https://custom.example/v1',
|
|
||||||
api_keys=['custom-key'],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(LLMModel).values(
|
|
||||||
uuid='custom-model',
|
|
||||||
workspace_uuid=WORKSPACE_A,
|
|
||||||
name='custom-model',
|
|
||||||
provider_uuid='custom-provider',
|
|
||||||
abilities=['chat'],
|
|
||||||
extra_args={},
|
|
||||||
prefered_ranking=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
first = await service.sync_once()
|
|
||||||
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_B) == 5000
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
providers = (
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.select(
|
|
||||||
ModelProvider.uuid,
|
|
||||||
ModelProvider.workspace_uuid,
|
|
||||||
ModelProvider.api_keys,
|
|
||||||
).where(ModelProvider.requester == 'space-chat-completions')
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B}
|
|
||||||
assert {item.uuid for item in providers} == {
|
|
||||||
system_provider_uuid(WORKSPACE_A),
|
|
||||||
system_provider_uuid(WORKSPACE_B),
|
|
||||||
}
|
|
||||||
assert {item.workspace_uuid: item.api_keys for item in providers} == {
|
|
||||||
WORKSPACE_A: ['owner-a-key'],
|
|
||||||
WORKSPACE_B: ['owner-b-key'],
|
|
||||||
}
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2
|
|
||||||
|
|
||||||
second = await service.sync_once()
|
|
||||||
assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
|
|
||||||
provider.snapshot = _snapshot(
|
|
||||||
key_a='new-owner-key',
|
|
||||||
model_id='gpt-renamed',
|
|
||||||
include_embedding=False,
|
|
||||||
)
|
|
||||||
third = await service.sync_once()
|
|
||||||
assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2}
|
|
||||||
assert reload_counter.calls == 2
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_a_keys = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
|
||||||
)
|
|
||||||
assert provider_a_keys == ['new-owner-key']
|
|
||||||
system_model_names = (
|
|
||||||
(
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.select(LLMModel.name).where(
|
|
||||||
LLMModel.provider_uuid.in_(
|
|
||||||
[system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert set(system_model_names) == {'gpt-renamed'}
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0
|
|
||||||
assert (
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(ModelProvider)
|
|
||||||
.where(ModelProvider.uuid == 'custom-provider')
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(LLMModel)
|
|
||||||
.where(LLMModel.uuid == 'custom-model')
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
|
|
||||||
provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False)
|
|
||||||
fourth = await service.sync_once()
|
|
||||||
assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 3
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_a_keys = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
|
||||||
)
|
|
||||||
assert provider_a_keys == []
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None:
|
|
||||||
assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A)
|
|
||||||
assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B)
|
|
||||||
assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream')
|
|
||||||
snapshot = _snapshot()
|
|
||||||
assert 'owner-a-key' not in repr(snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_must_cover_every_active_workspace() -> None:
|
|
||||||
snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]})
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(
|
|
||||||
list_active_execution_bindings=lambda: _async_value(
|
|
||||||
[SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID)
|
|
||||||
with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'):
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_periodic_sync_discovers_workspace_created_after_startup_cache_release(tmp_path) -> None:
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog-new-workspace.db"}')
|
|
||||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
||||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
||||||
startup_bindings = [
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1)
|
|
||||||
]
|
|
||||||
live_bindings = [
|
|
||||||
*startup_bindings,
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
|
||||||
]
|
|
||||||
|
|
||||||
class _WorkspaceService:
|
|
||||||
startup_released = False
|
|
||||||
|
|
||||||
async def list_active_execution_bindings(self):
|
|
||||||
return list(live_bindings if self.startup_released else startup_bindings)
|
|
||||||
|
|
||||||
def release_startup_execution_bindings(self):
|
|
||||||
self.startup_released = True
|
|
||||||
|
|
||||||
workspace_service = _WorkspaceService()
|
|
||||||
app = SimpleNamespace(
|
|
||||||
persistence_mgr=manager,
|
|
||||||
workspace_service=workspace_service,
|
|
||||||
model_mgr=SimpleNamespace(load_models_from_db=_AsyncCounter()),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(Workspace),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_A,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'A',
|
|
||||||
'slug': 'a',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_B,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'B',
|
|
||||||
'slug': 'b',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.initialize()
|
|
||||||
workspace_service.release_startup_execution_bindings()
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_b = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_B))
|
|
||||||
)
|
|
||||||
assert provider_b is not None
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_run_wakes_immediately_when_directory_changes() -> None:
|
|
||||||
sync_started = asyncio.Event()
|
|
||||||
|
|
||||||
class _WakeService(CloudModelCatalogSyncService):
|
|
||||||
async def sync_once(self, *, reload_runtime: bool = True):
|
|
||||||
del reload_runtime
|
|
||||||
sync_started.set()
|
|
||||||
return {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
|
|
||||||
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
|
||||||
service = _WakeService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID, sync_interval_seconds=3600)
|
|
||||||
task = asyncio.create_task(service.run())
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
service.request_sync()
|
|
||||||
await asyncio.wait_for(sync_started.wait(), timeout=0.2)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_value(value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class _AsyncCounter:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
async def __call__(self) -> None:
|
|
||||||
self.calls += 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_partial_workspace_failure_reloads_already_committed_changes() -> None:
|
|
||||||
bindings = [
|
|
||||||
SimpleNamespace(workspace_uuid=WORKSPACE_A),
|
|
||||||
SimpleNamespace(workspace_uuid=WORKSPACE_B),
|
|
||||||
]
|
|
||||||
reload_counter = _AsyncCounter()
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
|
|
||||||
model_mgr=SimpleNamespace(load_models_from_db=reload_counter),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def sync_workspace(*_args):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls == 1:
|
|
||||||
return {'created': 1, 'updated': 0, 'deleted': 0}
|
|
||||||
raise RuntimeError('second Workspace failed')
|
|
||||||
|
|
||||||
service._sync_workspace = sync_workspace # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='second Workspace failed'):
|
|
||||||
await service.sync_once()
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_B) is None
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_runtime_reload_is_retried_after_noop_sync() -> None:
|
|
||||||
bindings = [SimpleNamespace(workspace_uuid=WORKSPACE_A)]
|
|
||||||
|
|
||||||
class _FlakyReload:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
async def __call__(self) -> None:
|
|
||||||
self.calls += 1
|
|
||||||
if self.calls == 1:
|
|
||||||
raise RuntimeError('reload failed')
|
|
||||||
|
|
||||||
runtime_reload = _FlakyReload()
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
|
|
||||||
model_mgr=SimpleNamespace(load_models_from_db=runtime_reload),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def sync_workspace(*_args):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls == 1:
|
|
||||||
return {'created': 1, 'updated': 0, 'deleted': 0}
|
|
||||||
return {'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
|
|
||||||
service._sync_workspace = sync_workspace # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='reload failed'):
|
|
||||||
await service.sync_once()
|
|
||||||
summary = await service.sync_once()
|
|
||||||
assert summary == {'workspaces': 1, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
assert runtime_reload.calls == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_background_sync_log_redacts_exception_message(caplog) -> None:
|
|
||||||
secret = 'owner-secret-api-key'
|
|
||||||
attempted = asyncio.Event()
|
|
||||||
|
|
||||||
class _FailingProvider:
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
del instance_uuid
|
|
||||||
attempted.set()
|
|
||||||
raise RuntimeError(f'database parameters include {secret}')
|
|
||||||
|
|
||||||
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
|
||||||
service = CloudModelCatalogSyncService(app, _FailingProvider(), INSTANCE_UUID)
|
|
||||||
service.sync_interval_seconds = 0.001
|
|
||||||
task = asyncio.create_task(service.run())
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(attempted.wait(), timeout=1)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|
||||||
assert secret not in caplog.text
|
|
||||||
assert 'Cloud model catalog synchronization failed (RuntimeError)' in caplog.text
|
|
||||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
@@ -961,13 +961,11 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
|||||||
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
||||||
sa.select(
|
sa.select(
|
||||||
sa.func.now(),
|
sa.func.now(),
|
||||||
sa.func.date_trunc('hour', sa.column('timestamp')),
|
|
||||||
sa.func.length(sa.literal('value')),
|
sa.func.length(sa.literal('value')),
|
||||||
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
||||||
),
|
),
|
||||||
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
||||||
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
||||||
sa.select(sa.cast(sa.column('embedding'), HALFVEC(3072))),
|
|
||||||
sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
|
sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
|
||||||
_multi_value_statement(value=1),
|
_multi_value_statement(value=1),
|
||||||
_on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
|
_on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|||||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||||
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
||||||
from langbot.pkg.pipeline.controller import Controller
|
from langbot.pkg.pipeline.controller import Controller
|
||||||
from langbot.pkg.pipeline.pool import QueryPool
|
|
||||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +28,57 @@ def _prepare_scheduler(mock_app):
|
|||||||
return query_pool, session
|
return query_pool, session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_consumer_schedules_query_after_running_transition(
|
||||||
|
mock_app,
|
||||||
|
sample_query,
|
||||||
|
):
|
||||||
|
query_pool = MagicMock()
|
||||||
|
query_pool.queries = [sample_query]
|
||||||
|
query_pool.__aenter__ = AsyncMock(return_value=query_pool)
|
||||||
|
query_pool.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
query_pool.remove_query = AsyncMock(return_value=True)
|
||||||
|
wait_for_query = asyncio.Event()
|
||||||
|
query_pool.condition = SimpleNamespace(
|
||||||
|
wait=AsyncMock(side_effect=wait_for_query.wait),
|
||||||
|
notify_all=Mock(),
|
||||||
|
)
|
||||||
|
query_pool.mark_query_running_locked = Mock(side_effect=query_pool.queries.remove)
|
||||||
|
mock_app.query_pool = query_pool
|
||||||
|
|
||||||
|
session = SimpleNamespace(_semaphore=asyncio.Semaphore(1))
|
||||||
|
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||||
|
runtime_pipeline = SimpleNamespace(run=AsyncMock())
|
||||||
|
mock_app.pipeline_mgr = SimpleNamespace(get_pipeline_by_uuid=AsyncMock(return_value=runtime_pipeline))
|
||||||
|
|
||||||
|
task_created = asyncio.Event()
|
||||||
|
process_tasks = []
|
||||||
|
|
||||||
|
def create_process_task(coro, **_kwargs):
|
||||||
|
process_tasks.append(asyncio.create_task(coro))
|
||||||
|
task_created.set()
|
||||||
|
|
||||||
|
mock_app.task_mgr.create_task = Mock(side_effect=create_process_task)
|
||||||
|
controller = Controller(mock_app)
|
||||||
|
initial_slots = controller.semaphore._value
|
||||||
|
consumer_task = asyncio.create_task(controller.consumer())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task_created.wait(), timeout=2)
|
||||||
|
finally:
|
||||||
|
consumer_task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await consumer_task
|
||||||
|
await asyncio.gather(*process_tasks)
|
||||||
|
|
||||||
|
query_pool.mark_query_running_locked.assert_called_once_with(sample_query)
|
||||||
|
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||||
|
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||||
|
assert query_pool.queries == []
|
||||||
|
assert session._semaphore._value == 1
|
||||||
|
assert controller.semaphore._value == initial_slots
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_controller_drops_stale_query_before_pipeline_lookup(
|
async def test_controller_drops_stale_query_before_pipeline_lookup(
|
||||||
mock_app,
|
mock_app,
|
||||||
@@ -144,31 +194,3 @@ async def test_controller_revalidates_generation_before_running_pipeline(
|
|||||||
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||||
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||||
session._semaphore.release.assert_called_once_with()
|
session._semaphore.release.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_controller_schedules_query_without_removing_it_twice(mock_app, sample_query):
|
|
||||||
query_pool = QueryPool()
|
|
||||||
query_pool.queries.append(sample_query)
|
|
||||||
mock_app.query_pool = query_pool
|
|
||||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace(_semaphore=asyncio.Semaphore(1)))
|
|
||||||
|
|
||||||
scheduler_errors: list[str] = []
|
|
||||||
|
|
||||||
def stop_on_scheduler_error(message):
|
|
||||||
scheduler_errors.append(str(message))
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
|
|
||||||
def stop_after_scheduling(process_coro, **_kwargs):
|
|
||||||
process_coro.close()
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
|
|
||||||
mock_app.logger.error.side_effect = stop_on_scheduler_error
|
|
||||||
mock_app.task_mgr.create_task.side_effect = stop_after_scheduling
|
|
||||||
controller = Controller(mock_app)
|
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await controller.consumer()
|
|
||||||
|
|
||||||
assert scheduler_errors == []
|
|
||||||
assert query_pool.queries == []
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextvars
|
import contextvars
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
|
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
|
||||||
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
|
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
|
||||||
from langbot.pkg.platform.sources.websocket_manager import (
|
from langbot.pkg.platform.sources.websocket_manager import (
|
||||||
@@ -206,7 +208,7 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pipeline_override_survives_detached_listener_task(monkeypatch):
|
async def test_pipeline_override_is_set_inside_detached_listener_task(monkeypatch):
|
||||||
manager = WebSocketConnectionManager()
|
manager = WebSocketConnectionManager()
|
||||||
connection = await manager.add_connection(
|
connection = await manager.add_connection(
|
||||||
websocket=Mock(),
|
websocket=Mock(),
|
||||||
@@ -221,21 +223,21 @@ async def test_pipeline_override_survives_detached_listener_task(monkeypatch):
|
|||||||
self.tasks = []
|
self.tasks = []
|
||||||
|
|
||||||
def create_task(self, coro, **_kwargs):
|
def create_task(self, coro, **_kwargs):
|
||||||
task = asyncio.create_task(coro, context=contextvars.Context())
|
task = asyncio.get_running_loop().create_task(coro, context=contextvars.Context())
|
||||||
self.tasks.append(task)
|
self.tasks.append(task)
|
||||||
return Mock(task=task)
|
return SimpleNamespace(task=task)
|
||||||
|
|
||||||
task_manager = DetachedTaskManager()
|
task_manager = DetachedTaskManager()
|
||||||
adapter = WebSocketAdapter.model_construct(
|
adapter = WebSocketAdapter.model_construct(
|
||||||
ap=Mock(task_mgr=task_manager),
|
ap=SimpleNamespace(task_mgr=task_manager),
|
||||||
logger=_adapter_logger(),
|
logger=_adapter_logger(),
|
||||||
)
|
)
|
||||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||||
pipeline_overrides = []
|
received_pipeline_uuids = []
|
||||||
|
|
||||||
async def listener(_event, callback_adapter):
|
async def listener(_event, callback_adapter):
|
||||||
pipeline_overrides.append(callback_adapter.get_pipeline_uuid_override())
|
received_pipeline_uuids.append(callback_adapter.get_pipeline_uuid_override())
|
||||||
|
|
||||||
adapter.listeners = {platform_events.FriendMessage: listener}
|
adapter.listeners = {platform_events.FriendMessage: listener}
|
||||||
await adapter.handle_websocket_message(
|
await adapter.handle_websocket_message(
|
||||||
@@ -244,7 +246,7 @@ async def test_pipeline_override_survives_detached_listener_task(monkeypatch):
|
|||||||
)
|
)
|
||||||
await asyncio.gather(*task_manager.tasks)
|
await asyncio.gather(*task_manager.tasks)
|
||||||
|
|
||||||
assert pipeline_overrides == ['pipeline-1']
|
assert received_pipeline_uuids == ['pipeline-1']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -343,6 +345,49 @@ async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dashboard_reply_survives_connection_replacement(monkeypatch):
|
||||||
|
manager = WebSocketConnectionManager()
|
||||||
|
original = await manager.add_connection(
|
||||||
|
websocket=Mock(),
|
||||||
|
scope=SCOPE_A,
|
||||||
|
pipeline_uuid='pipeline-1',
|
||||||
|
session_type='person',
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
||||||
|
|
||||||
|
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
|
||||||
|
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||||
|
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||||
|
received = []
|
||||||
|
|
||||||
|
async def listener(event, _callback_adapter):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
adapter.listeners = {platform_events.FriendMessage: listener}
|
||||||
|
await adapter.handle_websocket_message(
|
||||||
|
original,
|
||||||
|
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await manager.remove_connection(original.connection_id)
|
||||||
|
replacement = await manager.add_connection(
|
||||||
|
websocket=Mock(),
|
||||||
|
scope=SCOPE_A,
|
||||||
|
pipeline_uuid='pipeline-1',
|
||||||
|
session_type='person',
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.reply_message(
|
||||||
|
received[0],
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text='done')]),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await replacement.send_queue.get()
|
||||||
|
assert response['type'] == 'response'
|
||||||
|
assert response['data']['content'] == 'done'
|
||||||
|
|
||||||
|
|
||||||
def test_session_ids_must_be_canonical_random_uuids():
|
def test_session_ids_must_be_canonical_random_uuids():
|
||||||
assert is_valid_session_id('31c0f2e9-b115-4ee6-8f15-3e624d6456b1')
|
assert is_valid_session_id('31c0f2e9-b115-4ee6-8f15-3e624d6456b1')
|
||||||
assert not is_valid_session_id('session-a')
|
assert not is_valid_session_id('session-a')
|
||||||
|
|||||||
@@ -612,13 +612,8 @@ class TestDisabledPluginEarlyReturns:
|
|||||||
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
mock_app.instance_config.data = {'plugin': {'enable': False}}
|
||||||
|
|
||||||
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
|
||||||
execution_context = connector_module.ExecutionContext(
|
|
||||||
instance_uuid='instance-a',
|
|
||||||
workspace_uuid='workspace-a',
|
|
||||||
placement_generation=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await connector.get_debug_info(execution_context)
|
result = await connector.get_debug_info()
|
||||||
|
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
|
|||||||
@@ -282,11 +282,12 @@ def test_closed_deployment_selects_instance_scoped_shared_profile():
|
|||||||
assert connector.runtime_profile == 'shared'
|
assert connector.runtime_profile == 'shared'
|
||||||
|
|
||||||
|
|
||||||
def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeypatch):
|
def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
|
||||||
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
|
||||||
connector = make_connector()
|
connector = make_connector()
|
||||||
|
|
||||||
assert connector._control_headers(allow_generate=False) == {}
|
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
|
||||||
|
connector._control_headers(allow_generate=False)
|
||||||
|
|
||||||
|
|
||||||
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
|
||||||
|
|||||||
@@ -1304,6 +1304,7 @@ class TestScanModels:
|
|||||||
)
|
)
|
||||||
requester._supports_function_calling = Mock(side_effect=lambda model_id: model_id == 'gpt-4o')
|
requester._supports_function_calling = Mock(side_effect=lambda model_id: model_id == 'gpt-4o')
|
||||||
requester._supports_vision = Mock(side_effect=lambda model_id: model_id == 'gpt-4o')
|
requester._supports_vision = Mock(side_effect=lambda model_id: model_id == 'gpt-4o')
|
||||||
|
requester._supports_reasoning = Mock(side_effect=lambda model_id: model_id == 'o3')
|
||||||
requester._safe_context_length = Mock(side_effect=lambda model_id: 128000 if model_id == 'gpt-4o' else None)
|
requester._safe_context_length = Mock(side_effect=lambda model_id: 128000 if model_id == 'gpt-4o' else None)
|
||||||
|
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
@@ -1311,6 +1312,7 @@ class TestScanModels:
|
|||||||
return_value={
|
return_value={
|
||||||
'data': [
|
'data': [
|
||||||
{'id': 'gpt-4o'},
|
{'id': 'gpt-4o'},
|
||||||
|
{'id': 'o3'},
|
||||||
{'id': 'text-embedding-3-small'},
|
{'id': 'text-embedding-3-small'},
|
||||||
{'id': 'bge-reranker-v2'},
|
{'id': 'bge-reranker-v2'},
|
||||||
]
|
]
|
||||||
@@ -1327,6 +1329,7 @@ class TestScanModels:
|
|||||||
by_id = {model['id']: model for model in result['models']}
|
by_id = {model['id']: model for model in result['models']}
|
||||||
assert by_id['gpt-4o']['abilities'] == ['func_call', 'vision']
|
assert by_id['gpt-4o']['abilities'] == ['func_call', 'vision']
|
||||||
assert by_id['gpt-4o']['context_length'] == 128000
|
assert by_id['gpt-4o']['context_length'] == 128000
|
||||||
|
assert by_id['o3']['abilities'] == ['reasoning']
|
||||||
assert by_id['text-embedding-3-small']['type'] == 'embedding'
|
assert by_id['text-embedding-3-small']['type'] == 'embedding'
|
||||||
assert by_id['bge-reranker-v2']['type'] == 'rerank'
|
assert by_id['bge-reranker-v2']['type'] == 'rerank'
|
||||||
|
|
||||||
@@ -1374,8 +1377,8 @@ class TestScanModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
|
with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
|
||||||
mock_get_model_info.side_effect = (
|
mock_get_model_info.side_effect = lambda model: (
|
||||||
lambda model: {'max_input_tokens': 131072} if model == 'moonshot/moonshot-v1-128k' else {}
|
{'max_input_tokens': 131072} if model == 'moonshot/moonshot-v1-128k' else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert requester._safe_context_length('moonshot-v1-128k') == 131072
|
assert requester._safe_context_length('moonshot-v1-128k') == 131072
|
||||||
@@ -1404,8 +1407,8 @@ class TestScanModels:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
|
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
|
||||||
mock_supports_function_calling.side_effect = (
|
mock_supports_function_calling.side_effect = lambda model, custom_llm_provider=None: (
|
||||||
lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' and custom_llm_provider is None
|
model == 'moonshot/kimi-k2.6' and custom_llm_provider is None
|
||||||
)
|
)
|
||||||
|
|
||||||
assert requester._supports_function_calling('kimi-k2.6') is True
|
assert requester._supports_function_calling('kimi-k2.6') is True
|
||||||
|
|||||||
@@ -249,7 +249,11 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
|
|||||||
'ai': {
|
'ai': {
|
||||||
'runner': {'runner': 'local-agent'},
|
'runner': {'runner': 'local-agent'},
|
||||||
'local-agent': {
|
'local-agent': {
|
||||||
'model': {'primary': model_uuid, 'fallbacks': []},
|
'model': {
|
||||||
|
'primary': model_uuid,
|
||||||
|
'fallbacks': [],
|
||||||
|
'reasoning': {model_uuid: 'high'},
|
||||||
|
},
|
||||||
'prompt': [],
|
'prompt': [],
|
||||||
'knowledge-bases': [],
|
'knowledge-bases': [],
|
||||||
},
|
},
|
||||||
@@ -293,3 +297,134 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
|
|||||||
candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query)
|
candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query)
|
||||||
|
|
||||||
assert [model.model_entity.uuid for model in candidates] == [model_uuid]
|
assert [model.model_entity.uuid for model in candidates] == [model_uuid]
|
||||||
|
assert candidates[0].reasoning_config_override == {'level': 'high'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_agent_applies_reasoning_per_fallback_model():
|
||||||
|
execution_context = ExecutionContext(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
placement_generation=1,
|
||||||
|
)
|
||||||
|
provider = Mock(
|
||||||
|
execution_context=execution_context,
|
||||||
|
provider_entity=persistence_model.ModelProvider(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='provider',
|
||||||
|
name='provider',
|
||||||
|
requester='openai',
|
||||||
|
base_url='https://example.com',
|
||||||
|
api_keys=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
primary = requester.RuntimeLLMModel(
|
||||||
|
execution_context,
|
||||||
|
persistence_model.LLMModel(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='primary-model',
|
||||||
|
name='primary',
|
||||||
|
provider_uuid='provider',
|
||||||
|
abilities=['reasoning'],
|
||||||
|
extra_args={},
|
||||||
|
),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
fallback = requester.RuntimeLLMModel(
|
||||||
|
execution_context,
|
||||||
|
persistence_model.LLMModel(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='fallback-model',
|
||||||
|
name='fallback',
|
||||||
|
provider_uuid='provider',
|
||||||
|
abilities=['reasoning'],
|
||||||
|
extra_args={},
|
||||||
|
),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
models = {'primary-model': primary, 'fallback-model': fallback}
|
||||||
|
runner = SimpleNamespace(
|
||||||
|
ap=SimpleNamespace(
|
||||||
|
model_mgr=SimpleNamespace(
|
||||||
|
get_model_by_uuid=AsyncMock(side_effect=lambda _context, model_uuid: models[model_uuid]),
|
||||||
|
),
|
||||||
|
logger=Mock(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
query = SimpleNamespace(
|
||||||
|
use_llm_model_uuid='primary-model',
|
||||||
|
variables={'_fallback_model_uuids': ['fallback-model']},
|
||||||
|
pipeline_config={
|
||||||
|
'ai': {
|
||||||
|
'local-agent': {
|
||||||
|
'model': {
|
||||||
|
'primary': 'primary-model',
|
||||||
|
'fallbacks': ['fallback-model'],
|
||||||
|
'reasoning': {
|
||||||
|
'primary-model': 'low',
|
||||||
|
'fallback-model': 'high',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_execution_context=execution_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = await LocalAgentRunner._get_model_candidates(runner, query)
|
||||||
|
|
||||||
|
assert [candidate.reasoning_config_override for candidate in candidates] == [
|
||||||
|
{'level': 'low'},
|
||||||
|
{'level': 'high'},
|
||||||
|
]
|
||||||
|
assert candidates[0] is not primary
|
||||||
|
assert candidates[1] is not fallback
|
||||||
|
assert primary.reasoning_config_override is None
|
||||||
|
assert fallback.reasoning_config_override is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_agent_rejects_invalid_pipeline_reasoning_level():
|
||||||
|
execution_context = ExecutionContext(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
placement_generation=1,
|
||||||
|
)
|
||||||
|
provider = Mock(
|
||||||
|
execution_context=execution_context,
|
||||||
|
provider_entity=persistence_model.ModelProvider(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='provider',
|
||||||
|
name='provider',
|
||||||
|
requester='openai',
|
||||||
|
base_url='https://example.com',
|
||||||
|
api_keys=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
model = requester.RuntimeLLMModel(
|
||||||
|
execution_context,
|
||||||
|
persistence_model.LLMModel(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='primary-model',
|
||||||
|
name='primary',
|
||||||
|
provider_uuid='provider',
|
||||||
|
abilities=['reasoning'],
|
||||||
|
extra_args={},
|
||||||
|
),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
query = SimpleNamespace(
|
||||||
|
pipeline_config={
|
||||||
|
'ai': {
|
||||||
|
'local-agent': {
|
||||||
|
'model': {
|
||||||
|
'primary': 'primary-model',
|
||||||
|
'fallbacks': [],
|
||||||
|
'reasoning': {'primary-model': 'turbo'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Unsupported reasoning level'):
|
||||||
|
LocalAgentRunner._apply_pipeline_reasoning_config(query, model)
|
||||||
|
|||||||
@@ -0,0 +1,758 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
from langbot.pkg.entity.persistence import model as persistence_model
|
||||||
|
from langbot.pkg.provider.modelmgr import errors, reasoning, requester
|
||||||
|
from langbot.pkg.provider.modelmgr.requesters import litellmchat
|
||||||
|
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||||
|
from langbot.pkg.provider.runners.localagent import _StreamAccumulator
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_model(
|
||||||
|
request: LiteLLMRequester,
|
||||||
|
level: str = 'provider_default',
|
||||||
|
name: str = 'reasoning-model',
|
||||||
|
abilities: list[str] | None = None,
|
||||||
|
requester_name: str | None = None,
|
||||||
|
) -> requester.RuntimeLLMModel:
|
||||||
|
execution_context = ExecutionContext(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
placement_generation=1,
|
||||||
|
)
|
||||||
|
entity = persistence_model.LLMModel(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='reasoning-model',
|
||||||
|
name=name,
|
||||||
|
provider_uuid='provider-test',
|
||||||
|
abilities=abilities if abilities is not None else ['reasoning'],
|
||||||
|
reasoning_config={'level': level},
|
||||||
|
extra_args={},
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(
|
||||||
|
execution_context=execution_context,
|
||||||
|
provider_entity=persistence_model.ModelProvider(
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
uuid='provider-test',
|
||||||
|
name='provider',
|
||||||
|
requester=requester_name or request.requester_cfg.get('requester_name') or 'custom-requester',
|
||||||
|
base_url='https://example.com',
|
||||||
|
api_keys=[],
|
||||||
|
),
|
||||||
|
requester=request,
|
||||||
|
token_mgr=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
return requester.RuntimeLLMModel(execution_context, entity, provider)
|
||||||
|
|
||||||
|
|
||||||
|
def _requester(provider: str = '', requester_name: str = '') -> LiteLLMRequester:
|
||||||
|
return LiteLLMRequester(
|
||||||
|
SimpleNamespace(),
|
||||||
|
{
|
||||||
|
'custom_llm_provider': provider,
|
||||||
|
'requester_name': requester_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_config_normalization_and_conflicts():
|
||||||
|
assert reasoning.normalize_reasoning_config(None) == {'level': 'provider_default'}
|
||||||
|
assert reasoning.normalize_reasoning_config({}) == {'level': 'provider_default'}
|
||||||
|
assert reasoning.validate_reasoning_config(
|
||||||
|
{'level': 'high'},
|
||||||
|
['reasoning'],
|
||||||
|
{},
|
||||||
|
) == {'level': 'high'}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Unsupported reasoning level'):
|
||||||
|
reasoning.normalize_reasoning_config({'level': 'turbo'})
|
||||||
|
with pytest.raises(ValueError, match='reasoning ability'):
|
||||||
|
reasoning.validate_reasoning_config({'level': 'low'}, [], {})
|
||||||
|
with pytest.raises(ValueError, match='extra_body.thinking_budget'):
|
||||||
|
reasoning.validate_reasoning_config(
|
||||||
|
{'level': 'low'},
|
||||||
|
['reasoning'],
|
||||||
|
{'extra_body': {'thinking_budget': 1024}},
|
||||||
|
)
|
||||||
|
assert reasoning.find_reasoning_arg_conflicts(
|
||||||
|
{
|
||||||
|
'enable_thinking': True,
|
||||||
|
'extra_body': {'reasoning_effort': 'high'},
|
||||||
|
}
|
||||||
|
) == ['enable_thinking', 'extra_body.reasoning_effort']
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_reasoning_model_without_known_protocol_stays_conservative(monkeypatch):
|
||||||
|
request = _requester()
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request))
|
||||||
|
|
||||||
|
assert capabilities == {
|
||||||
|
'supported': True,
|
||||||
|
'levels': ['provider_default'],
|
||||||
|
'source': 'manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_protocol_does_not_mark_unknown_models_as_reasoning(monkeypatch):
|
||||||
|
request = _requester('openai', 'openai-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(
|
||||||
|
_runtime_model(request, name='future-reasoning-model', abilities=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert capabilities == {
|
||||||
|
'supported': False,
|
||||||
|
'levels': ['provider_default'],
|
||||||
|
'source': 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_unmarked_model_without_provider_stays_safe(monkeypatch):
|
||||||
|
request = _requester()
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='unknown-model', abilities=[]))
|
||||||
|
|
||||||
|
assert capabilities == {
|
||||||
|
'supported': False,
|
||||||
|
'levels': ['provider_default'],
|
||||||
|
'source': 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mimo_exposes_off_on_without_fake_effort_levels(monkeypatch):
|
||||||
|
request = _requester('openai', 'mimo-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='mimo-v2.5', abilities=[]))
|
||||||
|
|
||||||
|
assert capabilities == {
|
||||||
|
'supported': True,
|
||||||
|
'levels': ['provider_default', 'disabled', 'enabled'],
|
||||||
|
'source': 'provider',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_reasoning_levels_follow_litellm_metadata(monkeypatch):
|
||||||
|
request = _requester('openai', 'openai-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
request,
|
||||||
|
'_safe_model_info',
|
||||||
|
lambda _: {
|
||||||
|
'supports_none_reasoning_effort': True,
|
||||||
|
'supports_minimal_reasoning_effort': False,
|
||||||
|
'supports_low_reasoning_effort': True,
|
||||||
|
'supports_xhigh_reasoning_effort': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name='gpt-5'))
|
||||||
|
|
||||||
|
assert capabilities['source'] == 'litellm'
|
||||||
|
assert capabilities['levels'] == [
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_adaptive_and_always_on_profiles(monkeypatch):
|
||||||
|
request = _requester('anthropic', 'anthropic-messages')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
adaptive = request.get_reasoning_capabilities(_runtime_model(request, name='claude-sonnet-4-6', abilities=[]))
|
||||||
|
assert adaptive['levels'] == [
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
'max',
|
||||||
|
]
|
||||||
|
|
||||||
|
always_on = request.get_reasoning_capabilities(_runtime_model(request, name='claude-fable-5', abilities=[]))
|
||||||
|
assert 'disabled' not in always_on['levels']
|
||||||
|
|
||||||
|
legacy = request.get_reasoning_capabilities(_runtime_model(request, name='claude-3-5-sonnet', abilities=[]))
|
||||||
|
assert legacy['levels'] == ['provider_default', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_profiles_match_model_generation(monkeypatch):
|
||||||
|
request = _requester('deepseek', 'deepseek-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
assert request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-v4-flash', abilities=[]))[
|
||||||
|
'levels'
|
||||||
|
] == ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
|
||||||
|
assert request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-chat', abilities=[]))[
|
||||||
|
'levels'
|
||||||
|
] == ['provider_default', 'disabled', 'enabled']
|
||||||
|
assert request.get_reasoning_capabilities(_runtime_model(request, name='deepseek-r1', abilities=[]))['levels'] == [
|
||||||
|
'provider_default'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('model_name', 'expected_levels'),
|
||||||
|
[
|
||||||
|
('kimi-k3', ['provider_default', 'low', 'high', 'max']),
|
||||||
|
('kimi-k2.7-code', ['provider_default']),
|
||||||
|
('kimi-k2.6', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
('kimi-k2.5', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_kimi_profiles(model_name, expected_levels, monkeypatch):
|
||||||
|
request = _requester('openai', 'moonshot-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[]))
|
||||||
|
|
||||||
|
assert capabilities['levels'] == expected_levels
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen_mixed_and_dedicated_thinking_profiles(monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
mixed = request.get_reasoning_capabilities(_runtime_model(request, name='qwen-plus', abilities=[]))
|
||||||
|
dedicated = request.get_reasoning_capabilities(
|
||||||
|
_runtime_model(request, name='qwen3-235b-a22b-thinking-2507', abilities=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mixed['levels'] == ['provider_default', 'disabled', 'enabled']
|
||||||
|
assert dedicated['levels'] == ['provider_default', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen3_exposes_budget_based_reasoning_levels(monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
mixed = request.get_reasoning_capabilities(_runtime_model(request, name='qwen3.8-max', abilities=[]))
|
||||||
|
dedicated = request.get_reasoning_capabilities(
|
||||||
|
_runtime_model(request, name='qwen3.7-max-preview', abilities=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mixed['levels'] == ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
|
assert mixed['legacy_levels'] == ['enabled']
|
||||||
|
assert dedicated['levels'] == ['provider_default', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen3_legacy_enabled_config_remains_supported(monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
assert request._build_reasoning_args(_runtime_model(request, 'enabled', name='qwen3.8-max')) == {
|
||||||
|
'extra_body': {'enable_thinking': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('level', 'budget'),
|
||||||
|
[('low', 1024), ('medium', 4096), ('high', 8192)],
|
||||||
|
)
|
||||||
|
def test_qwen3_reasoning_levels_translate_to_thinking_budget(level, budget, monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
assert request._build_reasoning_args(_runtime_model(request, level, name='qwen3.8-max')) == {
|
||||||
|
'extra_body': {
|
||||||
|
'enable_thinking': True,
|
||||||
|
'thinking_budget': budget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('model_name', ['qwen3.7-max-preview', 'qwen3.7-max-2026-05-17'])
|
||||||
|
def test_qwen_dedicated_thinking_release_models_are_not_toggleable(model_name, monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[]))
|
||||||
|
|
||||||
|
assert capabilities['levels'] == ['provider_default', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('model_name', 'expected_levels'),
|
||||||
|
[
|
||||||
|
('kimi-k2.6', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
('kimi-k2.5', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
('kimi-k2.7-code', ['provider_default']),
|
||||||
|
('kimi-k2-thinking', ['provider_default']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bailian_kimi_profiles_use_kimi_model_rules(model_name, expected_levels, monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[]))
|
||||||
|
|
||||||
|
assert capabilities['levels'] == expected_levels
|
||||||
|
|
||||||
|
|
||||||
|
def test_bailian_kimi_uses_thinking_protocol_instead_of_qwen_protocol(monkeypatch):
|
||||||
|
request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
assert request._build_reasoning_args(_runtime_model(request, 'disabled', name='kimi-k2.6')) == {
|
||||||
|
'extra_body': {'thinking': {'type': 'disabled'}}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_doubao_exposes_documented_effort_range(monkeypatch):
|
||||||
|
request = _requester('openai', 'doubao-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(
|
||||||
|
_runtime_model(request, name='doubao-seed-2-1-pro-260628', abilities=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert capabilities['levels'] == ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('model_name', 'expected_levels'),
|
||||||
|
[
|
||||||
|
('gpt-5', ['provider_default', 'low', 'medium', 'high']),
|
||||||
|
(
|
||||||
|
'claude-sonnet-4-6',
|
||||||
|
['provider_default', 'disabled', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||||
|
),
|
||||||
|
('deepseek-v4-flash', ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']),
|
||||||
|
('kimi-k2.6', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
('qwen-plus', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
('doubao-seed-2-1-pro-260628', ['provider_default', 'disabled', 'low', 'medium', 'high']),
|
||||||
|
('mimo-v2.5', ['provider_default', 'disabled', 'enabled']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_new_api_infers_upstream_protocol_from_model_name(model_name, expected_levels, monkeypatch):
|
||||||
|
request = _requester('openai', 'new-api-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(_runtime_model(request, name=model_name, abilities=[]))
|
||||||
|
|
||||||
|
assert capabilities['levels'] == expected_levels
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('provider', 'requester_name', 'model_name'),
|
||||||
|
[
|
||||||
|
('openai', 'openai-chat-completions', 'gpt-5'),
|
||||||
|
('anthropic', 'anthropic-messages', 'claude-sonnet-4-6'),
|
||||||
|
('deepseek', 'deepseek-chat-completions', 'deepseek-v4-flash'),
|
||||||
|
('openai', 'mimo-chat-completions', 'mimo-v2.5'),
|
||||||
|
('openai', 'moonshot-chat-completions', 'kimi-k2.6'),
|
||||||
|
('openai', 'bailian-chat-completions', 'qwen-plus'),
|
||||||
|
('openai', 'doubao-chat-completions', 'doubao-seed-2-1-pro-260628'),
|
||||||
|
('openai', 'new-api-chat-completions', 'deepseek-v4-flash'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_scanned_known_models_gain_reasoning_ability(provider, requester_name, model_name, monkeypatch):
|
||||||
|
request = _requester(provider, requester_name)
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
monkeypatch.setattr(request, '_supports_function_calling', lambda _: False)
|
||||||
|
monkeypatch.setattr(request, '_supports_vision', lambda _: False)
|
||||||
|
monkeypatch.setattr(request, '_safe_context_length', lambda _: None)
|
||||||
|
|
||||||
|
scanned = request._enrich_scanned_model(model_name)
|
||||||
|
|
||||||
|
assert scanned['abilities'] == ['reasoning']
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_api_unknown_alias_stays_conservative(monkeypatch):
|
||||||
|
request = _requester('openai', 'new-api-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
|
||||||
|
capabilities = request.get_reasoning_capabilities(
|
||||||
|
_runtime_model(request, name='company-internal-alias', abilities=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert capabilities == {
|
||||||
|
'supported': False,
|
||||||
|
'levels': ['provider_default'],
|
||||||
|
'source': 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_argument_translation(monkeypatch):
|
||||||
|
openai_request = _requester('openai', 'openai-chat-completions')
|
||||||
|
monkeypatch.setattr(openai_request, '_supports_reasoning', lambda _: True)
|
||||||
|
monkeypatch.setattr(openai_request, '_safe_model_info', lambda _: {'supports_none_reasoning_effort': True})
|
||||||
|
assert openai_request._build_reasoning_args(_runtime_model(openai_request, 'disabled', name='gpt-5')) == {
|
||||||
|
'reasoning_effort': 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
anthropic_request = _requester('anthropic', 'anthropic-messages')
|
||||||
|
assert anthropic_request._build_reasoning_args(
|
||||||
|
_runtime_model(anthropic_request, 'disabled', name='claude-sonnet-4-6')
|
||||||
|
) == {'thinking': {'type': 'disabled'}}
|
||||||
|
|
||||||
|
deepseek_request = _requester('deepseek', 'deepseek-chat-completions')
|
||||||
|
assert deepseek_request._build_reasoning_args(
|
||||||
|
_runtime_model(deepseek_request, 'high', name='deepseek-v4-flash')
|
||||||
|
) == {
|
||||||
|
'extra_body': {
|
||||||
|
'thinking': {'type': 'enabled'},
|
||||||
|
'reasoning_effort': 'high',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kimi_request = _requester('openai', 'moonshot-chat-completions')
|
||||||
|
assert kimi_request._build_reasoning_args(_runtime_model(kimi_request, 'enabled', name='kimi-k2.6')) == {
|
||||||
|
'extra_body': {'thinking': {'type': 'enabled'}}
|
||||||
|
}
|
||||||
|
assert kimi_request._build_reasoning_args(_runtime_model(kimi_request, 'high', name='kimi-k3')) == {
|
||||||
|
'reasoning_effort': 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
qwen_request = _requester('openai', 'bailian-chat-completions')
|
||||||
|
assert qwen_request._build_reasoning_args(_runtime_model(qwen_request, 'disabled', name='qwen-plus')) == {
|
||||||
|
'extra_body': {'enable_thinking': False}
|
||||||
|
}
|
||||||
|
|
||||||
|
doubao_request = _requester('openai', 'doubao-chat-completions')
|
||||||
|
assert doubao_request._build_reasoning_args(
|
||||||
|
_runtime_model(doubao_request, 'high', name='doubao-seed-2-1-pro-260628')
|
||||||
|
) == {'reasoning_effort': 'high'}
|
||||||
|
|
||||||
|
mimo_request = _requester('openai', 'mimo-chat-completions')
|
||||||
|
assert mimo_request._build_reasoning_args(_runtime_model(mimo_request, 'disabled', name='mimo-v2.5')) == {
|
||||||
|
'extra_body': {'thinking': {'type': 'disabled'}}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_reasoning_override_takes_precedence(monkeypatch):
|
||||||
|
request = _requester('openai', 'openai-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
|
||||||
|
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
|
||||||
|
model = _runtime_model(request, 'high', name='gpt-5')
|
||||||
|
|
||||||
|
model.reasoning_config_override = {'level': 'provider_default'}
|
||||||
|
assert request._build_reasoning_args(model) == {}
|
||||||
|
|
||||||
|
model.reasoning_config_override = {'level': 'low'}
|
||||||
|
assert request._build_reasoning_args(model) == {'reasoning_effort': 'low'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_always_on_reasoning_models_do_not_offer_disabled(monkeypatch):
|
||||||
|
deepseek_request = _requester('deepseek', 'deepseek-chat-completions')
|
||||||
|
monkeypatch.setattr(deepseek_request, '_supports_reasoning', lambda _: True)
|
||||||
|
monkeypatch.setattr(deepseek_request, '_safe_model_info', lambda _: {})
|
||||||
|
deepseek_capabilities = deepseek_request.get_reasoning_capabilities(
|
||||||
|
_runtime_model(deepseek_request, name='deepseek-r1')
|
||||||
|
)
|
||||||
|
assert deepseek_capabilities['levels'] == ['provider_default']
|
||||||
|
|
||||||
|
gemini_request = _requester('gemini')
|
||||||
|
monkeypatch.setattr(gemini_request, '_supports_reasoning', lambda _: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gemini_request,
|
||||||
|
'_safe_model_info',
|
||||||
|
lambda _: {'supports_none_reasoning_effort': True},
|
||||||
|
)
|
||||||
|
gemini_capabilities = gemini_request.get_reasoning_capabilities(_runtime_model(gemini_request, name='gemini-3-pro'))
|
||||||
|
assert 'disabled' not in gemini_capabilities['levels']
|
||||||
|
with pytest.raises(errors.RequesterError, match='not supported'):
|
||||||
|
gemini_request._build_reasoning_args(_runtime_model(gemini_request, 'disabled', name='gemini-3-pro'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_target_provider_capabilities_remain_supported(monkeypatch):
|
||||||
|
ollama_request = _requester('ollama', 'ollama')
|
||||||
|
monkeypatch.setattr(ollama_request, '_supports_reasoning', lambda _: False)
|
||||||
|
monkeypatch.setattr(ollama_request, '_safe_model_info', lambda _: {})
|
||||||
|
|
||||||
|
toggle_capabilities = ollama_request.get_reasoning_capabilities(_runtime_model(ollama_request, name='qwen3'))
|
||||||
|
assert toggle_capabilities['levels'] == [
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'enabled',
|
||||||
|
]
|
||||||
|
assert ollama_request._build_reasoning_args(_runtime_model(ollama_request, 'enabled', name='qwen3')) == {
|
||||||
|
'reasoning_effort': 'low'
|
||||||
|
}
|
||||||
|
|
||||||
|
effort_capabilities = ollama_request.get_reasoning_capabilities(_runtime_model(ollama_request, name='gpt-oss:20b'))
|
||||||
|
assert effort_capabilities['levels'] == [
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
]
|
||||||
|
assert ollama_request._build_reasoning_args(_runtime_model(ollama_request, 'high', name='gpt-oss:20b')) == {
|
||||||
|
'reasoning_effort': 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
volcengine_request = _requester('volcengine', 'volcark-chat-completions')
|
||||||
|
monkeypatch.setattr(volcengine_request, '_supports_reasoning', lambda _: False)
|
||||||
|
monkeypatch.setattr(volcengine_request, '_safe_model_info', lambda _: {})
|
||||||
|
assert volcengine_request._build_reasoning_args(
|
||||||
|
_runtime_model(volcengine_request, 'disabled', name='doubao-seed')
|
||||||
|
) == {'extra_body': {'thinking': {'type': 'disabled'}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_unsupported_level_raises(monkeypatch):
|
||||||
|
request = _requester()
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
|
||||||
|
|
||||||
|
with pytest.raises(errors.RequesterError, match='Available levels: provider_default'):
|
||||||
|
request._build_reasoning_args(_runtime_model(request, 'high', abilities=[]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_inference_rejects_levels_outside_conservative_profile(monkeypatch):
|
||||||
|
request = _requester('openai', 'openai-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
|
||||||
|
|
||||||
|
with pytest.raises(errors.RequesterError, match='Available levels: provider_default, low, medium, high'):
|
||||||
|
request._build_reasoning_args(_runtime_model(request, 'xhigh', name='gpt-5', abilities=[]))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_completion_args_reject_reasoning_extra_arg_conflicts(monkeypatch):
|
||||||
|
request = _requester('openai', 'openai-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
|
||||||
|
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
|
||||||
|
model = _runtime_model(request, 'high', name='gpt-5')
|
||||||
|
model.model_entity.extra_args = {'reasoning_effort': 'low'}
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
|
||||||
|
with pytest.raises(errors.RequesterError, match='conflicts with advanced parameters'):
|
||||||
|
await request._build_completion_args(model, [])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_compatible_reasoning_effort_is_explicitly_allowed(monkeypatch):
|
||||||
|
request = _requester('openai', 'moonshot-chat-completions')
|
||||||
|
model = _runtime_model(request, 'high', name='kimi-k3')
|
||||||
|
model.model_entity.extra_args = {'allowed_openai_params': ['custom_extension']}
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
|
||||||
|
args = await request._build_completion_args(model, [])
|
||||||
|
|
||||||
|
assert args['reasoning_effort'] == 'high'
|
||||||
|
assert args['allowed_openai_params'] == ['custom_extension', 'reasoning_effort']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_default_does_not_allow_or_send_reasoning_effort():
|
||||||
|
request = _requester('openai', 'new-api-chat-completions')
|
||||||
|
model = _runtime_model(request, 'provider_default', name='deepseek-v4-flash')
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
|
||||||
|
args = await request._build_completion_args(model, [])
|
||||||
|
|
||||||
|
assert 'reasoning_effort' not in args
|
||||||
|
assert 'allowed_openai_params' not in args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deepseek_disabled_thinking_is_merged_into_extra_body(monkeypatch):
|
||||||
|
request = _requester('deepseek', 'deepseek-chat-completions')
|
||||||
|
monkeypatch.setattr(request, '_supports_reasoning', lambda _: False)
|
||||||
|
model = _runtime_model(request, 'disabled', name='deepseek-chat')
|
||||||
|
model.model_entity.extra_args = {'extra_body': {'custom_extension': True}}
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
|
||||||
|
args = await request._build_completion_args(model, [])
|
||||||
|
|
||||||
|
assert args['extra_body'] == {
|
||||||
|
'custom_extension': True,
|
||||||
|
'thinking': {'type': 'disabled'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openai_compatible_reasoning_history_is_promoted_for_tool_continuity():
|
||||||
|
request = _requester('openai', 'mimo-chat-completions')
|
||||||
|
model = _runtime_model(request, 'enabled', name='mimo-v2.5')
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
history = [
|
||||||
|
provider_message.Message(
|
||||||
|
role='assistant',
|
||||||
|
content='',
|
||||||
|
provider_specific_fields={'reasoning_content': 'prior reasoning'},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
args = await request._build_completion_args(model, history)
|
||||||
|
|
||||||
|
assert args['messages'][0]['reasoning_content'] == 'prior reasoning'
|
||||||
|
assert 'provider_specific_fields' not in args['messages'][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabling_reasoning_removes_previous_reasoning_context():
|
||||||
|
request = _requester('openai', 'mimo-chat-completions')
|
||||||
|
model = _runtime_model(request, 'disabled', name='mimo-v2.5')
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
history = [
|
||||||
|
provider_message.Message(
|
||||||
|
role='assistant',
|
||||||
|
content='answer',
|
||||||
|
provider_specific_fields={'reasoning_content': 'prior reasoning'},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
args = await request._build_completion_args(model, history)
|
||||||
|
|
||||||
|
assert 'reasoning_content' not in args['messages'][0]
|
||||||
|
assert 'provider_specific_fields' not in args['messages'][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anthropic_history_promotes_thinking_blocks_instead_of_reasoning_content():
|
||||||
|
request = _requester('anthropic', 'anthropic-messages')
|
||||||
|
model = _runtime_model(request, 'high', name='claude-sonnet-4-6')
|
||||||
|
model.provider.token_mgr.get_token = lambda: 'test-token'
|
||||||
|
thinking_blocks = [{'type': 'thinking', 'thinking': 'prior reasoning', 'signature': 'sig'}]
|
||||||
|
history = [
|
||||||
|
provider_message.Message(
|
||||||
|
role='assistant',
|
||||||
|
content='',
|
||||||
|
provider_specific_fields={
|
||||||
|
'reasoning_content': 'prior reasoning',
|
||||||
|
'thinking_blocks': thinking_blocks,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
args = await request._build_completion_args(model, history)
|
||||||
|
|
||||||
|
assert args['messages'][0]['thinking_blocks'] == thinking_blocks
|
||||||
|
assert 'reasoning_content' not in args['messages'][0]
|
||||||
|
assert 'provider_specific_fields' not in args['messages'][0]
|
||||||
|
|
||||||
|
|
||||||
|
class _Dumpable:
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def model_dump(self) -> dict:
|
||||||
|
return dict(self.data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_stream_reasoning_content_is_preserved(monkeypatch):
|
||||||
|
request = _requester('deepseek')
|
||||||
|
request._build_completion_args = AsyncMock(return_value={})
|
||||||
|
response = SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
message=_Dumpable(
|
||||||
|
{
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': 'answer',
|
||||||
|
'reasoning_content': 'private reasoning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=response))
|
||||||
|
|
||||||
|
message, _ = await request.invoke_llm(None, _runtime_model(request), [], remove_think=True)
|
||||||
|
|
||||||
|
assert message.content == 'answer'
|
||||||
|
assert message.provider_specific_fields == {'reasoning_content': 'private reasoning'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_reasoning_round_trip_with_hidden_display(monkeypatch):
|
||||||
|
request = _requester('deepseek')
|
||||||
|
request._build_completion_args = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
async def chunks():
|
||||||
|
yield SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
delta=_Dumpable({'role': 'assistant', 'reasoning_content': 'private '}),
|
||||||
|
finish_reason=None,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=None,
|
||||||
|
)
|
||||||
|
yield SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
delta=_Dumpable({'content': 'answer'}),
|
||||||
|
finish_reason='stop',
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=chunks()))
|
||||||
|
accumulator = _StreamAccumulator(remove_think=True)
|
||||||
|
emitted: provider_message.MessageChunk | None = None
|
||||||
|
|
||||||
|
async for chunk in request.invoke_llm_stream(
|
||||||
|
None,
|
||||||
|
_runtime_model(request),
|
||||||
|
[],
|
||||||
|
remove_think=True,
|
||||||
|
):
|
||||||
|
emitted = accumulator.add(chunk) or emitted
|
||||||
|
|
||||||
|
assert emitted is not None
|
||||||
|
assert emitted.content == 'answer'
|
||||||
|
assert emitted.provider_specific_fields == {'reasoning_content': 'private '}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_reasoning_content_is_wrapped_for_display(monkeypatch):
|
||||||
|
request = _requester('deepseek')
|
||||||
|
request._build_completion_args = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
async def chunks():
|
||||||
|
yield SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
delta=_Dumpable({'role': 'assistant', 'reasoning_content': 'private '}),
|
||||||
|
finish_reason=None,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=None,
|
||||||
|
)
|
||||||
|
yield SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
delta=_Dumpable({'content': 'answer'}),
|
||||||
|
finish_reason='stop',
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(litellmchat, 'acompletion', AsyncMock(return_value=chunks()))
|
||||||
|
accumulator = _StreamAccumulator(remove_think=False)
|
||||||
|
emitted: provider_message.MessageChunk | None = None
|
||||||
|
|
||||||
|
async for chunk in request.invoke_llm_stream(
|
||||||
|
None,
|
||||||
|
_runtime_model(request),
|
||||||
|
[],
|
||||||
|
remove_think=False,
|
||||||
|
):
|
||||||
|
emitted = accumulator.add(chunk) or emitted
|
||||||
|
|
||||||
|
assert emitted is not None
|
||||||
|
assert emitted.content == '<think>\nprivate \n</think>\nanswer'
|
||||||
|
assert emitted.provider_specific_fields == {'reasoning_content': 'private '}
|
||||||
@@ -400,6 +400,7 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da
|
|||||||
assert model.model_entity.abilities == model_entity.abilities
|
assert model.model_entity.abilities == model_entity.abilities
|
||||||
assert model.model_entity.extra_args == model_entity.extra_args
|
assert model.model_entity.extra_args == model_entity.extra_args
|
||||||
assert model.provider is not None
|
assert model.provider is not None
|
||||||
|
assert model.reasoning_config_override is None
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_llm_model_provider_ref(runtime_llm_model):
|
def test_runtime_llm_model_provider_ref(runtime_llm_model):
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,12 +14,6 @@ def get_heartbeat_module():
|
|||||||
return import_module('langbot.pkg.telemetry.heartbeat')
|
return import_module('langbot.pkg.telemetry.heartbeat')
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_created_timestamp_treats_naive_database_values_as_utc():
|
|
||||||
heartbeat = get_heartbeat_module()
|
|
||||||
created_at = datetime(2026, 8, 4, 0, 0, 0)
|
|
||||||
assert heartbeat._workspace_created_timestamp(created_at) == 1785801600
|
|
||||||
|
|
||||||
|
|
||||||
def make_app():
|
def make_app():
|
||||||
ap = Mock()
|
ap = Mock()
|
||||||
ap.instance_config = Mock()
|
ap.instance_config = Mock()
|
||||||
@@ -64,17 +57,15 @@ def make_app():
|
|||||||
|
|
||||||
class TestBuildHeartbeatPayload:
|
class TestBuildHeartbeatPayload:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_payload_shape(self, monkeypatch):
|
async def test_payload_shape(self):
|
||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
|
|
||||||
ap = make_app()
|
ap = make_app()
|
||||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||||
|
|
||||||
assert payload['event_type'] == 'instance_heartbeat'
|
assert payload['event_type'] == 'instance_heartbeat'
|
||||||
assert payload['query_id'] == ''
|
assert payload['query_id'] == ''
|
||||||
assert payload['workspace_uuid'] == 'workspace-a'
|
assert payload['workspace_uuid'] == 'workspace-a'
|
||||||
assert payload['instance_id']
|
assert 'instance_id' not in payload
|
||||||
assert payload['workspace_create_ts'] == 0
|
|
||||||
assert 'instance_create_ts' in payload
|
assert 'instance_create_ts' in payload
|
||||||
assert 'timestamp' in payload
|
assert 'timestamp' in payload
|
||||||
f = payload['features']
|
f = payload['features']
|
||||||
@@ -109,9 +100,8 @@ class TestBuildHeartbeatPayload:
|
|||||||
assert payload['features']['pipeline_count'] == -1
|
assert payload['features']['pipeline_count'] == -1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cloud_counts_loaded_registries_without_tenant_sql(self, monkeypatch):
|
async def test_cloud_counts_loaded_registries_without_tenant_sql(self):
|
||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
|
|
||||||
ap = make_app()
|
ap = make_app()
|
||||||
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(
|
ap.persistence_mgr.execute_async = AsyncMock(
|
||||||
@@ -149,12 +139,8 @@ class TestBuildHeartbeatPayload:
|
|||||||
}
|
}
|
||||||
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
||||||
return_value=[
|
return_value=[
|
||||||
SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
|
SimpleNamespace(workspace_uuid='workspace-a'),
|
||||||
SimpleNamespace(
|
SimpleNamespace(workspace_uuid='workspace-b'),
|
||||||
workspace_uuid='workspace-b',
|
|
||||||
placement_generation=9,
|
|
||||||
workspace_created_at=datetime(2026, 8, 4, tzinfo=timezone.utc),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
||||||
@@ -164,9 +150,7 @@ class TestBuildHeartbeatPayload:
|
|||||||
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
||||||
|
|
||||||
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
||||||
assert all(payload['instance_id'] for payload in payloads)
|
assert all('instance_id' not in payload for payload in payloads)
|
||||||
assert payloads[0]['workspace_create_ts'] == 0
|
|
||||||
assert payloads[1]['workspace_create_ts'] == 1785801600
|
|
||||||
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
||||||
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
||||||
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
||||||
@@ -175,12 +159,10 @@ class TestBuildHeartbeatPayload:
|
|||||||
assert by_workspace['workspace-a']['plugin_count'] == 2
|
assert by_workspace['workspace-a']['plugin_count'] == 2
|
||||||
assert by_workspace['workspace-a']['extension_count'] == 5
|
assert by_workspace['workspace-a']['extension_count'] == 5
|
||||||
assert by_workspace['workspace-a']['skill_count'] == 2
|
assert by_workspace['workspace-a']['skill_count'] == 2
|
||||||
assert by_workspace['workspace-a']['execution_generation'] == 7
|
|
||||||
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
|
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
|
||||||
assert by_workspace['workspace-b']['bot_count'] == 1
|
assert by_workspace['workspace-b']['bot_count'] == 1
|
||||||
assert by_workspace['workspace-b']['pipeline_count'] == 0
|
assert by_workspace['workspace-b']['pipeline_count'] == 0
|
||||||
assert by_workspace['workspace-b']['skill_count'] == 1
|
assert by_workspace['workspace-b']['skill_count'] == 1
|
||||||
assert by_workspace['workspace-b']['execution_generation'] == 9
|
|
||||||
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
|
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
|
||||||
assert 'workspace_resources' not in by_workspace['workspace-a']
|
assert 'workspace_resources' not in by_workspace['workspace-a']
|
||||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|||||||
@@ -596,36 +596,6 @@ class TestTelemetryManagedRuntimeAuthentication:
|
|||||||
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
||||||
|
|
||||||
|
|
||||||
class TestAuthenticatedWorkspaceReporter:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_workspace_owner_access_token_is_sent_as_bearer(self):
|
|
||||||
telemetry = get_telemetry_module()
|
|
||||||
mock_app = Mock()
|
|
||||||
mock_app.logger = Mock()
|
|
||||||
mock_app.user_service = Mock()
|
|
||||||
mock_app.user_service.get_workspace_owner = AsyncMock(
|
|
||||||
return_value=Mock(user='owner@example.com', space_access_token='expired-token')
|
|
||||||
)
|
|
||||||
mock_app.space_service = Mock()
|
|
||||||
mock_app.space_service.get_valid_access_token = AsyncMock(return_value='refreshed-workspace-owner-token')
|
|
||||||
manager = telemetry.TelemetryManager(mock_app)
|
|
||||||
manager.telemetry_config = {'url': 'https://example.com'}
|
|
||||||
|
|
||||||
response = Mock(status_code=200, text='')
|
|
||||||
response.json = Mock(return_value={'code': 0})
|
|
||||||
mock_client = Mock()
|
|
||||||
mock_client.post = Mock(return_value=response)
|
|
||||||
|
|
||||||
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
|
|
||||||
await manager.send({'query_id': 'q-1', 'workspace_uuid': 'workspace-1'})
|
|
||||||
|
|
||||||
mock_app.user_service.get_workspace_owner.assert_awaited_once_with('workspace-1')
|
|
||||||
mock_app.space_service.get_valid_access_token.assert_awaited_once_with('owner@example.com')
|
|
||||||
assert mock_client.post.call_args.kwargs['headers'] == {
|
|
||||||
'Authorization': 'Bearer refreshed-workspace-owner-token'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartSendTask:
|
class TestStartSendTask:
|
||||||
"""Tests for start_send_task() method."""
|
"""Tests for start_send_task() method."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,28 +7,25 @@ from types import SimpleNamespace
|
|||||||
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||||
|
|
||||||
instance_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
|
||||||
|
|
||||||
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
||||||
assert workspace_uuid_from_instance_id(f'instance_{instance_uuid}') == instance_uuid
|
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
|
||||||
|
|
||||||
|
|
||||||
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||||
|
|
||||||
first = workspace_uuid_from_instance_id('instance_migration_test')
|
first = workspace_uuid_from_instance_id("instance_migration_test")
|
||||||
second = workspace_uuid_from_instance_id('instance_migration_test')
|
second = workspace_uuid_from_instance_id("instance_migration_test")
|
||||||
|
|
||||||
assert first == second
|
assert first == second
|
||||||
assert str(uuid.UUID(first)) == first
|
assert str(uuid.UUID(first)) == first
|
||||||
|
|
||||||
|
|
||||||
def test_query_telemetry_identity_reports_instance_and_workspace():
|
def test_query_telemetry_identity_uses_execution_workspace_only():
|
||||||
from langbot.pkg.telemetry.identity import workspace_identity
|
from langbot.pkg.telemetry.identity import workspace_identity
|
||||||
|
|
||||||
identity = workspace_identity(SimpleNamespace(workspace_uuid='workspace-a', instance_uuid='instance-a'))
|
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
|
||||||
|
|
||||||
assert identity == {
|
assert identity == {"workspace_uuid": "workspace-a"}
|
||||||
'instance_id': 'instance-a',
|
|
||||||
'workspace_uuid': 'workspace-a',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class TestVectorDBManagerInitialization:
|
|||||||
mock_app,
|
mock_app,
|
||||||
connection_string='postgresql://user:pass@host:5432/langbot',
|
connection_string='postgresql://user:pass@host:5432/langbot',
|
||||||
use_business_database=False,
|
use_business_database=False,
|
||||||
allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
|
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_initialize_pgvector_with_individual_params(self):
|
def test_initialize_pgvector_with_individual_params(self):
|
||||||
@@ -251,7 +251,7 @@ class TestVectorDBManagerInitialization:
|
|||||||
user='admin',
|
user='admin',
|
||||||
password='secret',
|
password='secret',
|
||||||
use_business_database=False,
|
use_business_database=False,
|
||||||
allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
|
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_initialize_pgvector_defaults(self):
|
def test_initialize_pgvector_defaults(self):
|
||||||
@@ -280,7 +280,7 @@ class TestVectorDBManagerInitialization:
|
|||||||
user='postgres',
|
user='postgres',
|
||||||
password='postgres',
|
password='postgres',
|
||||||
use_business_database=False,
|
use_business_database=False,
|
||||||
allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
|
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_initialize_pgvector_with_shared_business_database(self):
|
def test_initialize_pgvector_with_shared_business_database(self):
|
||||||
|
|||||||
@@ -203,21 +203,20 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
|
|||||||
second_membership,
|
second_membership,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
|
promoted = await service.update_member_role(
|
||||||
await service.update_member_role(
|
workspace.uuid,
|
||||||
workspace.uuid,
|
second.uuid,
|
||||||
second.uuid,
|
'owner',
|
||||||
'owner',
|
owner_membership,
|
||||||
owner_membership,
|
)
|
||||||
)
|
assert promoted.role == 'owner'
|
||||||
|
demoted = await service.update_member_role(
|
||||||
with pytest.raises(LastOwnerError):
|
workspace.uuid,
|
||||||
await service.update_member_role(
|
owner_membership.account_uuid,
|
||||||
workspace.uuid,
|
'admin',
|
||||||
owner_membership.account_uuid,
|
owner_membership,
|
||||||
'admin',
|
)
|
||||||
owner_membership,
|
assert demoted.role == 'admin'
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_selector_requires_membership(collaboration_context):
|
async def test_workspace_selector_requires_membership(collaboration_context):
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.4",
|
"@radix-ui/react-select": "^2.2.4",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.4",
|
"@radix-ui/react-switch": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.11",
|
"@radix-ui/react-tabs": "^1.1.11",
|
||||||
|
|||||||
Generated
+207
@@ -72,6 +72,9 @@ dependencies:
|
|||||||
'@radix-ui/react-separator':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.1.8
|
specifier: ^1.1.8
|
||||||
version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||||
|
'@radix-ui/react-slider':
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.4(@types/react@19.2.10)(react@19.2.1)
|
version: 1.2.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
@@ -555,10 +558,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/number@1.1.3:
|
||||||
|
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/primitive@1.1.3:
|
/@radix-ui/primitive@1.1.3:
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/primitive@1.1.7:
|
||||||
|
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -682,6 +693,29 @@ packages:
|
|||||||
react-dom: 19.2.1(react@19.2.1)
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||||
|
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -718,6 +752,19 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -769,6 +816,19 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-context@1.2.2(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -815,6 +875,19 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-direction@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1104,6 +1177,26 @@ packages:
|
|||||||
react-dom: 19.2.1(react@19.2.1)
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1281,6 +1374,36 @@ packages:
|
|||||||
react-dom: 19.2.1(react@19.2.1)
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.3
|
||||||
|
'@radix-ui/primitive': 1.1.7
|
||||||
|
'@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1(react@19.2.1)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1):
|
/@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1309,6 +1432,20 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-slot@1.3.3(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1469,6 +1606,22 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.7
|
||||||
|
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.1):
|
/@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1483,6 +1636,20 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
/@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1524,6 +1691,19 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
/@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1537,6 +1717,19 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-previous@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-use-rect@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
/@radix-ui/react-use-rect@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1565,6 +1758,20 @@ packages:
|
|||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-size@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||||
|
resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
/@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
beginAuthenticatedSession,
|
beginAuthenticatedSession,
|
||||||
beginSupportAdminSession,
|
beginSupportAdminSession,
|
||||||
bootstrapWorkspaceSession,
|
bootstrapWorkspaceSession,
|
||||||
clearPendingInvitationToken,
|
|
||||||
getPendingInvitationToken,
|
getPendingInvitationToken,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -67,10 +66,6 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const directLaunchFragmentRef = useRef<{
|
|
||||||
workspaceUuid: string | null;
|
|
||||||
launchAssertion: string | null;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
'loading' | 'confirm' | 'success' | 'error'
|
'loading' | 'confirm' | 'success' | 'error'
|
||||||
@@ -113,31 +108,8 @@ function SpaceOAuthCallbackContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beginAuthenticatedSession(response.token, response.user);
|
beginAuthenticatedSession(response.token, response.user);
|
||||||
const invitationToken = getPendingInvitationToken();
|
if (getPendingInvitationToken()) {
|
||||||
if (invitationToken) {
|
navigate('/invitations/accept', { replace: true });
|
||||||
let invitation;
|
|
||||||
try {
|
|
||||||
invitation =
|
|
||||||
await httpClient.acceptWorkspaceInvitation(invitationToken);
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as { code?: string }).code;
|
|
||||||
const path = code
|
|
||||||
? `/invitations/accept?error=${encodeURIComponent(code)}`
|
|
||||||
: '/invitations/accept';
|
|
||||||
navigate(path, { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
beginAuthenticatedSession(invitation.token, response.user);
|
|
||||||
clearPendingInvitationToken();
|
|
||||||
const workspaceResult = await bootstrapWorkspaceSession({
|
|
||||||
preferredWorkspaceUuid: invitation.workspace_uuid,
|
|
||||||
});
|
|
||||||
if (workspaceResult.status === 'unavailable') {
|
|
||||||
navigate('/workspace-unavailable', { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate('/home', { replace: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceResult = await bootstrapWorkspaceSession({
|
const workspaceResult = await bootstrapWorkspaceSession({
|
||||||
@@ -248,28 +220,8 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const errorDescription = searchParams.get('error_description');
|
const errorDescription = searchParams.get('error_description');
|
||||||
const mode = searchParams.get('mode');
|
const mode = searchParams.get('mode');
|
||||||
const state = searchParams.get('state');
|
const state = searchParams.get('state');
|
||||||
if (directLaunchFragmentRef.current === null) {
|
const workspaceUuid = searchParams.get('workspace_uuid');
|
||||||
const fragmentParams = new URLSearchParams(
|
const launchAssertion = searchParams.get('launch_assertion');
|
||||||
window.location.hash.startsWith('#')
|
|
||||||
? window.location.hash.slice(1)
|
|
||||||
: window.location.hash,
|
|
||||||
);
|
|
||||||
directLaunchFragmentRef.current = {
|
|
||||||
workspaceUuid: fragmentParams.get('workspace_uuid'),
|
|
||||||
launchAssertion: fragmentParams.get('launch_assertion'),
|
|
||||||
};
|
|
||||||
if (window.location.hash) {
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
`${window.location.pathname}${window.location.search}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const workspaceUuid =
|
|
||||||
directLaunchFragmentRef.current.workspaceUuid ??
|
|
||||||
searchParams.get('workspace_uuid');
|
|
||||||
const launchAssertion = directLaunchFragmentRef.current.launchAssertion;
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ import type {
|
|||||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
||||||
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
|
||||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
|
||||||
|
|
||||||
type PopoverView = 'menu' | 'mcp' | 'github';
|
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||||
|
|
||||||
@@ -156,12 +154,6 @@ function AddExtensionContent() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||||
const { extensions: extensionQuota, extensionsReached } =
|
|
||||||
useWorkspaceQuotaStatus();
|
|
||||||
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
|
|
||||||
resource: t('sidebar.extensions'),
|
|
||||||
max: extensionQuota.max,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Localized label for an extension type, used in the install dialog.
|
// Localized label for an extension type, used in the install dialog.
|
||||||
const extensionTypeLabel = (type: string) =>
|
const extensionTypeLabel = (type: string) =>
|
||||||
@@ -352,28 +344,23 @@ function AddExtensionContent() {
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleInstallPlugin = useCallback(
|
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||||
async (plugin: PluginV4) => {
|
setInstallInfo({
|
||||||
if (extensionsReached) return;
|
plugin_author: plugin.author,
|
||||||
setInstallInfo({
|
plugin_name: plugin.name,
|
||||||
plugin_author: plugin.author,
|
plugin_version: plugin.latest_version,
|
||||||
plugin_name: plugin.name,
|
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||||
plugin_version: plugin.latest_version,
|
plugin_description: extractI18nObject(plugin.description) || '',
|
||||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
plugin_icon: plugin.icon || '',
|
||||||
plugin_description: extractI18nObject(plugin.description) || '',
|
});
|
||||||
plugin_icon: plugin.icon || '',
|
setInstallExtensionType(plugin.type || 'plugin');
|
||||||
});
|
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||||
setInstallExtensionType(plugin.type || 'plugin');
|
setInstallError(null);
|
||||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
setInstallIconFailed(false);
|
||||||
setInstallError(null);
|
setModalOpen(true);
|
||||||
setInstallIconFailed(false);
|
}, []);
|
||||||
setModalOpen(true);
|
|
||||||
},
|
|
||||||
[extensionsReached],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleModalConfirm() {
|
function handleModalConfirm() {
|
||||||
if (extensionsReached) return;
|
|
||||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||||
httpClient
|
httpClient
|
||||||
@@ -415,7 +402,6 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
if (extensionsReached) return;
|
|
||||||
if (!validateFileType(file)) {
|
if (!validateFileType(file)) {
|
||||||
toast.error(t('addExtension.unsupportedFileType'));
|
toast.error(t('addExtension.unsupportedFileType'));
|
||||||
return;
|
return;
|
||||||
@@ -435,15 +421,14 @@ function AddExtensionContent() {
|
|||||||
setSkillUploadPreviewOpen(true);
|
setSkillUploadPreviewOpen(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[extensionsReached, t, setSelectedTaskId],
|
[t, setSelectedTaskId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
if (extensionsReached) return;
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click();
|
fileInputRef.current.click();
|
||||||
}
|
}
|
||||||
}, [extensionsReached]);
|
}, []);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -470,13 +455,12 @@ function AddExtensionContent() {
|
|||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
if (extensionsReached) return;
|
|
||||||
const files = Array.from(event.dataTransfer.files);
|
const files = Array.from(event.dataTransfer.files);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
uploadFile(files[0]);
|
uploadFile(files[0]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[extensionsReached, uploadFile],
|
[uploadFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleMCPCreated(_serverName: string) {
|
function handleMCPCreated(_serverName: string) {
|
||||||
@@ -506,8 +490,7 @@ function AddExtensionContent() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('limitation.quotaCheckFailed'));
|
// If we can't check, let backend handle it
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -647,11 +630,9 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
async function handleGithubConfirm() {
|
async function handleGithubConfirm() {
|
||||||
if (!selectedAsset || !selectedRelease) return;
|
if (!selectedAsset || !selectedRelease) return;
|
||||||
|
if (!(await checkExtensionsLimit())) return;
|
||||||
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
||||||
if (!(await checkExtensionsLimit())) {
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||||
httpClient
|
httpClient
|
||||||
.installPluginFromGithub(
|
.installPluginFromGithub(
|
||||||
@@ -683,11 +664,9 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
async function handleGithubSkillConfirm() {
|
async function handleGithubSkillConfirm() {
|
||||||
if (!githubSkillInfo) return;
|
if (!githubSkillInfo) return;
|
||||||
|
if (!(await checkExtensionsLimit())) return;
|
||||||
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||||
if (!(await checkExtensionsLimit())) {
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await httpClient.installSkillFromGithub(
|
await httpClient.installSkillFromGithub(
|
||||||
githubURL.trim(),
|
githubURL.trim(),
|
||||||
@@ -747,24 +726,17 @@ function AddExtensionContent() {
|
|||||||
setPopoverOpen(open);
|
setPopoverOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WorkspaceQuotaTooltip
|
<PopoverTrigger asChild>
|
||||||
quota={extensionQuota}
|
<Button
|
||||||
resource={t('sidebar.extensions')}
|
variant="default"
|
||||||
>
|
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||||
<PopoverTrigger asChild>
|
>
|
||||||
<Button
|
<PlusIcon className="w-4 h-4" />
|
||||||
variant="default"
|
<span className="whitespace-nowrap">
|
||||||
disabled={extensionsReached}
|
{t('addExtension.manualAdd')}
|
||||||
aria-disabled={extensionsReached}
|
</span>
|
||||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
|
</Button>
|
||||||
>
|
</PopoverTrigger>
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
{t('addExtension.manualAdd')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</WorkspaceQuotaTooltip>
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
forceMount
|
forceMount
|
||||||
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
||||||
@@ -773,19 +745,9 @@ function AddExtensionContent() {
|
|||||||
{/* ===== Menu View ===== */}
|
{/* ===== Menu View ===== */}
|
||||||
{popoverView === 'menu' && (
|
{popoverView === 'menu' && (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{extensionsReached && (
|
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
|
||||||
{extensionQuotaTooltip}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* File upload area */}
|
{/* File upload area */}
|
||||||
<div
|
<div
|
||||||
aria-disabled={extensionsReached}
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
|
||||||
extensionsReached
|
|
||||||
? 'cursor-not-allowed opacity-50'
|
|
||||||
: 'cursor-pointer'
|
|
||||||
} ${
|
|
||||||
isDragOver
|
isDragOver
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||||
@@ -815,8 +777,7 @@ function AddExtensionContent() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={extensionsReached}
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
onClick={() => setPopoverView('mcp')}
|
onClick={() => setPopoverView('mcp')}
|
||||||
>
|
>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||||
@@ -835,8 +796,7 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={extensionsReached}
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
onClick={() => setPopoverView('github')}
|
onClick={() => setPopoverView('github')}
|
||||||
>
|
>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||||
@@ -855,8 +815,7 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={extensionsReached}
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!(await checkExtensionsLimit())) return;
|
if (!(await checkExtensionsLimit())) return;
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
@@ -923,7 +882,6 @@ function AddExtensionContent() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="mcp-form"
|
form="mcp-form"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={extensionsReached}
|
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
if (!(await checkExtensionsLimit())) {
|
if (!(await checkExtensionsLimit())) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -988,7 +946,6 @@ function AddExtensionContent() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubAddressSubmit}
|
onClick={handleGithubAddressSubmit}
|
||||||
disabled={
|
disabled={
|
||||||
extensionsReached ||
|
|
||||||
!githubURL.trim() ||
|
!githubURL.trim() ||
|
||||||
fetchingReleases ||
|
fetchingReleases ||
|
||||||
fetchingSkillPreview
|
fetchingSkillPreview
|
||||||
@@ -1145,11 +1102,7 @@ function AddExtensionContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button className="w-full" onClick={handleGithubConfirm}>
|
||||||
className="w-full"
|
|
||||||
onClick={handleGithubConfirm}
|
|
||||||
disabled={extensionsReached}
|
|
||||||
>
|
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1231,7 +1184,6 @@ function AddExtensionContent() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubSkillConfirm}
|
onClick={handleGithubSkillConfirm}
|
||||||
disabled={extensionsReached}
|
|
||||||
>
|
>
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1288,8 +1240,6 @@ function AddExtensionContent() {
|
|||||||
<MarketPage
|
<MarketPage
|
||||||
installPlugin={handleInstallPlugin}
|
installPlugin={handleInstallPlugin}
|
||||||
headerActions={extensionActions}
|
headerActions={extensionActions}
|
||||||
installDisabled={extensionsReached}
|
|
||||||
installDisabledTooltip={extensionQuotaTooltip}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1375,17 +1325,9 @@ function AddExtensionContent() {
|
|||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<WorkspaceQuotaTooltip
|
<Button onClick={handleModalConfirm}>
|
||||||
quota={extensionQuota}
|
{t('common.confirm')}
|
||||||
resource={t('sidebar.extensions')}
|
</Button>
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={handleModalConfirm}
|
|
||||||
disabled={extensionsReached}
|
|
||||||
>
|
|
||||||
{t('common.confirm')}
|
|
||||||
</Button>
|
|
||||||
</WorkspaceQuotaTooltip>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||||
@@ -1417,8 +1359,6 @@ function AddExtensionContent() {
|
|||||||
{pluginUploadPreviewFile && (
|
{pluginUploadPreviewFile && (
|
||||||
<PluginLocalPreviewPanel
|
<PluginLocalPreviewPanel
|
||||||
file={pluginUploadPreviewFile}
|
file={pluginUploadPreviewFile}
|
||||||
quota={extensionQuota}
|
|
||||||
quotaResource={t('sidebar.extensions')}
|
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setPluginUploadPreviewOpen(false);
|
setPluginUploadPreviewOpen(false);
|
||||||
setPluginUploadPreviewFile(null);
|
setPluginUploadPreviewFile(null);
|
||||||
@@ -1452,8 +1392,6 @@ function AddExtensionContent() {
|
|||||||
{skillUploadPreviewFile && (
|
{skillUploadPreviewFile && (
|
||||||
<SkillZipPreviewPanel
|
<SkillZipPreviewPanel
|
||||||
file={skillUploadPreviewFile}
|
file={skillUploadPreviewFile}
|
||||||
quota={extensionQuota}
|
|
||||||
quotaResource={t('sidebar.extensions')}
|
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setSkillUploadPreviewOpen(false);
|
setSkillUploadPreviewOpen(false);
|
||||||
setSkillUploadPreviewFile(null);
|
setSkillUploadPreviewFile(null);
|
||||||
|
|||||||
@@ -396,9 +396,10 @@ export default function BotForm({
|
|||||||
<form
|
<form
|
||||||
id="bot-form"
|
id="bot-form"
|
||||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
aria-busy={isLoading}
|
aria-busy={isLoading}
|
||||||
>
|
>
|
||||||
<fieldset className="space-y-6" disabled={isLoading}>
|
<fieldset className="contents" disabled={isLoading}>
|
||||||
{/* Card 1: Basic Information */}
|
{/* Card 1: Basic Information */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
|
|||||||
return z.object({
|
return z.object({
|
||||||
primary: z.string(),
|
primary: z.string(),
|
||||||
fallbacks: z.array(z.string()),
|
fallbacks: z.array(z.string()),
|
||||||
|
reasoning: z.record(z.string()),
|
||||||
});
|
});
|
||||||
case DynamicFormItemType.PROMPT_EDITOR:
|
case DynamicFormItemType.PROMPT_EDITOR:
|
||||||
return z.array(
|
return z.array(
|
||||||
@@ -488,12 +489,24 @@ export default function DynamicFormComponent({
|
|||||||
(v): v is string => typeof v === 'string',
|
(v): v is string => typeof v === 'string',
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
|
reasoning:
|
||||||
|
obj.reasoning != null &&
|
||||||
|
typeof obj.reasoning === 'object' &&
|
||||||
|
!Array.isArray(obj.reasoning)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(obj.reasoning).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
typeof entry[1] === 'string',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Legacy string format or any other unexpected type
|
// Legacy string format or any other unexpected type
|
||||||
return {
|
return {
|
||||||
primary: typeof value === 'string' ? value : '',
|
primary: typeof value === 'string' ? value : '',
|
||||||
fallbacks: [],
|
fallbacks: [],
|
||||||
|
reasoning: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (item.type === 'prompt-editor') {
|
if (item.type === 'prompt-editor') {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
EmbeddingModel,
|
EmbeddingModel,
|
||||||
RerankModel,
|
RerankModel,
|
||||||
PluginTool,
|
PluginTool,
|
||||||
|
ReasoningLevel,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -66,6 +67,9 @@ import SettingsDialog, {
|
|||||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||||
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
||||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
||||||
|
import ReasoningLevelPicker, {
|
||||||
|
REASONING_LEVELS,
|
||||||
|
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
||||||
|
|
||||||
function hasUsableUuid<T extends { uuid?: string | null }>(
|
function hasUsableUuid<T extends { uuid?: string | null }>(
|
||||||
item: T,
|
item: T,
|
||||||
@@ -874,7 +878,11 @@ export default function DynamicFormItemComponent({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rawModelValue = field.value;
|
const rawModelValue = field.value;
|
||||||
const modelValue: { primary: string; fallbacks: string[] } =
|
const modelValue: {
|
||||||
|
primary: string;
|
||||||
|
fallbacks: string[];
|
||||||
|
reasoning: Record<string, ReasoningLevel>;
|
||||||
|
} =
|
||||||
rawModelValue != null &&
|
rawModelValue != null &&
|
||||||
typeof rawModelValue === 'object' &&
|
typeof rawModelValue === 'object' &&
|
||||||
!Array.isArray(rawModelValue)
|
!Array.isArray(rawModelValue)
|
||||||
@@ -893,10 +901,29 @@ export default function DynamicFormItemComponent({
|
|||||||
.fallbacks as unknown[]
|
.fallbacks as unknown[]
|
||||||
).filter((v): v is string => typeof v === 'string')
|
).filter((v): v is string => typeof v === 'string')
|
||||||
: [],
|
: [],
|
||||||
|
reasoning:
|
||||||
|
(rawModelValue as Record<string, unknown>).reasoning != null &&
|
||||||
|
typeof (rawModelValue as Record<string, unknown>).reasoning ===
|
||||||
|
'object' &&
|
||||||
|
!Array.isArray(
|
||||||
|
(rawModelValue as Record<string, unknown>).reasoning,
|
||||||
|
)
|
||||||
|
? (Object.fromEntries(
|
||||||
|
Object.entries(
|
||||||
|
(rawModelValue as Record<string, unknown>)
|
||||||
|
.reasoning as Record<string, unknown>,
|
||||||
|
).filter(
|
||||||
|
(entry): entry is [string, ReasoningLevel] =>
|
||||||
|
typeof entry[1] === 'string' &&
|
||||||
|
REASONING_LEVELS.includes(entry[1] as ReasoningLevel),
|
||||||
|
),
|
||||||
|
) as Record<string, ReasoningLevel>)
|
||||||
|
: {},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
|
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
|
||||||
fallbacks: [],
|
fallbacks: [],
|
||||||
|
reasoning: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderModelSelect = (
|
const renderModelSelect = (
|
||||||
@@ -1043,20 +1070,79 @@ export default function DynamicFormItemComponent({
|
|||||||
field.onChange({ ...modelValue, ...patch });
|
field.onChange({ ...modelValue, ...patch });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateModelReasoning = (
|
||||||
|
modelUuid: string,
|
||||||
|
level: ReasoningLevel,
|
||||||
|
) => {
|
||||||
|
if (!modelUuid) return;
|
||||||
|
const updated = { ...modelValue.reasoning };
|
||||||
|
if (level === 'provider_default') {
|
||||||
|
delete updated[modelUuid];
|
||||||
|
} else {
|
||||||
|
updated[modelUuid] = level;
|
||||||
|
}
|
||||||
|
updateValue({ reasoning: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceModel = (
|
||||||
|
currentUuid: string,
|
||||||
|
nextUuid: string,
|
||||||
|
patch: Partial<typeof modelValue>,
|
||||||
|
) => {
|
||||||
|
const nextValue = { ...modelValue, ...patch };
|
||||||
|
const updatedReasoning = { ...modelValue.reasoning };
|
||||||
|
const currentModelStillSelected =
|
||||||
|
nextValue.primary === currentUuid ||
|
||||||
|
nextValue.fallbacks.includes(currentUuid);
|
||||||
|
if (
|
||||||
|
currentUuid &&
|
||||||
|
currentUuid !== nextUuid &&
|
||||||
|
!currentModelStillSelected
|
||||||
|
) {
|
||||||
|
delete updatedReasoning[currentUuid];
|
||||||
|
}
|
||||||
|
updateValue({ ...nextValue, reasoning: updatedReasoning });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderReasoningPicker = (modelUuid: string) => {
|
||||||
|
if (!modelUuid) return null;
|
||||||
|
const model = llmModels.find(
|
||||||
|
(candidate) => candidate.uuid === modelUuid,
|
||||||
|
);
|
||||||
|
const currentLevel =
|
||||||
|
modelValue.reasoning[modelUuid] || 'provider_default';
|
||||||
|
const availableLevels = model?.reasoning_capabilities?.levels || [
|
||||||
|
'provider_default',
|
||||||
|
];
|
||||||
|
const levels = REASONING_LEVELS.filter(
|
||||||
|
(level) => availableLevels.includes(level) || level === currentLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReasoningLevelPicker
|
||||||
|
value={currentLevel}
|
||||||
|
levels={levels}
|
||||||
|
onChange={(level) => updateModelReasoning(modelUuid, level)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const addFallbackModel = () => {
|
const addFallbackModel = () => {
|
||||||
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
|
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFallbackModel = (index: number, value: string) => {
|
const updateFallbackModel = (index: number, value: string) => {
|
||||||
const updated = [...modelValue.fallbacks];
|
const updated = [...modelValue.fallbacks];
|
||||||
|
const currentUuid = updated[index];
|
||||||
updated[index] = value;
|
updated[index] = value;
|
||||||
updateValue({ fallbacks: updated });
|
replaceModel(currentUuid, value, { fallbacks: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFallbackModel = (index: number) => {
|
const removeFallbackModel = (index: number) => {
|
||||||
const updated = [...modelValue.fallbacks];
|
const updated = [...modelValue.fallbacks];
|
||||||
|
const removedUuid = updated[index];
|
||||||
updated.splice(index, 1);
|
updated.splice(index, 1);
|
||||||
updateValue({ fallbacks: updated });
|
replaceModel(removedUuid, '', { fallbacks: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
|
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
|
||||||
@@ -1081,10 +1167,12 @@ export default function DynamicFormItemComponent({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{renderModelSelect(
|
{renderModelSelect(
|
||||||
modelValue.primary,
|
modelValue.primary,
|
||||||
(val) => updateValue({ primary: val }),
|
(val) =>
|
||||||
|
replaceModel(modelValue.primary, val, { primary: val }),
|
||||||
t('models.selectModel'),
|
t('models.selectModel'),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{renderReasoningPicker(modelValue.primary)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -1118,15 +1206,18 @@ export default function DynamicFormItemComponent({
|
|||||||
</p>
|
</p>
|
||||||
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
|
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
|
||||||
<div key={index} className="flex min-w-0 items-center gap-2">
|
<div key={index} className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground w-4 shrink-0">
|
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||||
{index + 1}.
|
{index + 1}.
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
{renderModelSelect(
|
<div className="min-w-0 flex-1">
|
||||||
fbUuid,
|
{renderModelSelect(
|
||||||
(val) => updateFallbackModel(index, val),
|
fbUuid,
|
||||||
t('models.selectModel'),
|
(val) => updateFallbackModel(index, val),
|
||||||
)}
|
t('models.selectModel'),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderReasoningPicker(fbUuid)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,6 +5,56 @@ export type DynamicFormSaveValueSpec = Pick<
|
|||||||
'default' | 'name' | 'type'
|
'default' | 'name' | 'type'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const reasoningLevels = new Set([
|
||||||
|
'disabled',
|
||||||
|
'enabled',
|
||||||
|
'minimal',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
'max',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeModelFallbackValue(value: unknown): {
|
||||||
|
primary: string;
|
||||||
|
fallbacks: string[];
|
||||||
|
reasoning: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const raw =
|
||||||
|
value != null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const primary =
|
||||||
|
typeof raw.primary === 'string'
|
||||||
|
? raw.primary
|
||||||
|
: typeof value === 'string'
|
||||||
|
? value
|
||||||
|
: '';
|
||||||
|
const fallbacks = Array.isArray(raw.fallbacks)
|
||||||
|
? raw.fallbacks.filter(
|
||||||
|
(fallback): fallback is string => typeof fallback === 'string',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const selectedModels = new Set([primary, ...fallbacks].filter(Boolean));
|
||||||
|
const rawReasoning =
|
||||||
|
raw.reasoning != null &&
|
||||||
|
typeof raw.reasoning === 'object' &&
|
||||||
|
!Array.isArray(raw.reasoning)
|
||||||
|
? (raw.reasoning as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const reasoning = Object.fromEntries(
|
||||||
|
Object.entries(rawReasoning).filter(
|
||||||
|
([modelUuid, level]) =>
|
||||||
|
selectedModels.has(modelUuid) &&
|
||||||
|
typeof level === 'string' &&
|
||||||
|
reasoningLevels.has(level),
|
||||||
|
),
|
||||||
|
) as Record<string, string>;
|
||||||
|
|
||||||
|
return { primary, fallbacks, reasoning };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the value snapshot emitted to parent forms for persistence.
|
* Build the value snapshot emitted to parent forms for persistence.
|
||||||
* Only single-line string fields trim surrounding whitespace; multiline text
|
* Only single-line string fields trim surrounding whitespace; multiline text
|
||||||
@@ -16,10 +66,14 @@ export function normalizeDynamicFormValuesForSave(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return specs.reduce<Record<string, unknown>>((values, spec) => {
|
return specs.reduce<Record<string, unknown>>((values, spec) => {
|
||||||
const value = formValues[spec.name] ?? spec.default;
|
const value = formValues[spec.name] ?? spec.default;
|
||||||
values[spec.name] =
|
if (spec.type === 'model-fallback-selector') {
|
||||||
spec.type === 'string' && typeof value === 'string'
|
values[spec.name] = normalizeModelFallbackValue(value);
|
||||||
? value.trim()
|
} else {
|
||||||
: value;
|
values[spec.name] =
|
||||||
|
spec.type === 'string' && typeof value === 'string'
|
||||||
|
? value.trim()
|
||||||
|
: value;
|
||||||
|
}
|
||||||
return values;
|
return values;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
clearUserInfo,
|
clearUserInfo,
|
||||||
getCloudServiceClientSync,
|
getCloudServiceClientSync,
|
||||||
useCurrentWorkspace,
|
useCurrentWorkspace,
|
||||||
useWorkspaceBootstrap,
|
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
HardDrive,
|
||||||
Server,
|
Server,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
@@ -109,11 +109,6 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
||||||
import { FeedbackPopoverContent } from './FeedbackPopover';
|
import { FeedbackPopoverContent } from './FeedbackPopover';
|
||||||
import {
|
|
||||||
type WorkspaceQuotaItem,
|
|
||||||
useWorkspaceQuotaStatus,
|
|
||||||
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
|
||||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
|
||||||
|
|
||||||
// Compare two version strings, returns true if v1 > v2
|
// Compare two version strings, returns true if v1 > v2
|
||||||
function compareVersions(v1: string, v2: string): boolean {
|
function compareVersions(v1: string, v2: string): boolean {
|
||||||
@@ -284,14 +279,6 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
|
|
||||||
count: 0,
|
|
||||||
max: -1,
|
|
||||||
reached: false,
|
|
||||||
loading: false,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function waitForMCPRefreshTask(taskId: number) {
|
async function waitForMCPRefreshTask(taskId: number) {
|
||||||
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||||
|
|
||||||
@@ -399,7 +386,6 @@ function NavItems({
|
|||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const sidebarData = useSidebarData();
|
const sidebarData = useSidebarData();
|
||||||
const quotaStatus = useWorkspaceQuotaStatus();
|
|
||||||
const { state: sidebarState, isMobile } = useSidebar();
|
const { state: sidebarState, isMobile } = useSidebar();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
@@ -543,7 +529,7 @@ function NavItems({
|
|||||||
if (config.id === 'add-extension' && !canManageResources) {
|
if (config.id === 'add-extension' && !canManageResources) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
|
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={config.id}>
|
<SidebarMenuItem key={config.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
@@ -589,18 +575,6 @@ function NavItems({
|
|||||||
const isSkill = categoryId === 'skills';
|
const isSkill = categoryId === 'skills';
|
||||||
const isBot = categoryId === 'bots';
|
const isBot = categoryId === 'bots';
|
||||||
const isMCP = categoryId === 'mcp';
|
const isMCP = categoryId === 'mcp';
|
||||||
const quota =
|
|
||||||
categoryId === 'bots'
|
|
||||||
? quotaStatus.bots
|
|
||||||
: categoryId === 'pipelines'
|
|
||||||
? quotaStatus.pipelines
|
|
||||||
: categoryId === 'knowledge'
|
|
||||||
? quotaStatus.knowledgeBases
|
|
||||||
: categoryId === 'plugins' ||
|
|
||||||
categoryId === 'mcp' ||
|
|
||||||
categoryId === 'skills'
|
|
||||||
? quotaStatus.extensions
|
|
||||||
: UNLIMITED_QUOTA;
|
|
||||||
|
|
||||||
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
||||||
if (item.extensionType === 'mcp') {
|
if (item.extensionType === 'mcp') {
|
||||||
@@ -933,144 +907,128 @@ function NavItems({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1 px-2">
|
<div className="flex items-center justify-between mb-1 px-2">
|
||||||
<span className="text-sm font-medium">{config.name}</span>
|
<span className="text-sm font-medium">{config.name}</span>
|
||||||
{canCreate && (
|
{canCreate &&
|
||||||
<WorkspaceQuotaTooltip
|
(isPlugin ? (
|
||||||
quota={quota}
|
<DropdownMenu>
|
||||||
resource={config.name}
|
<DropdownMenuTrigger asChild>
|
||||||
side="right"
|
<button
|
||||||
>
|
type="button"
|
||||||
{isPlugin ? (
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
<DropdownMenu>
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<Plus className="size-3.5" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</DropdownMenuTrigger>
|
||||||
disabled={quota.disabled}
|
<DropdownMenuContent align="end">
|
||||||
aria-disabled={quota.disabled}
|
{systemInfo.enable_marketplace && (
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{systemInfo.enable_marketplace && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate('/home/add-extension');
|
|
||||||
setPopoverOpen((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[config.id]: false,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Store className="size-4" />
|
|
||||||
{t('plugins.goToMarketplace')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate('/home/add-extension?manual=1');
|
navigate('/home/add-extension');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Store className="size-4" />
|
||||||
{t('plugins.uploadLocal')}
|
{t('plugins.goToMarketplace')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
)}
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate('/home/add-extension?manual=1');
|
e.stopPropagation();
|
||||||
setPopoverOpen((prev) => ({
|
navigate('/home/add-extension?manual=1');
|
||||||
...prev,
|
setPopoverOpen((prev) => ({
|
||||||
[config.id]: false,
|
...prev,
|
||||||
}));
|
[config.id]: false,
|
||||||
}}
|
}));
|
||||||
>
|
}}
|
||||||
<Github className="size-4" />
|
>
|
||||||
{t('plugins.installFromGithub')}
|
<Upload className="size-4" />
|
||||||
</DropdownMenuItem>
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
<DropdownMenuItem
|
||||||
) : isSkill ? (
|
onClick={(e) => {
|
||||||
<DropdownMenu>
|
e.stopPropagation();
|
||||||
<DropdownMenuTrigger asChild>
|
navigate('/home/add-extension?manual=1');
|
||||||
<button
|
setPopoverOpen((prev) => ({
|
||||||
type="button"
|
...prev,
|
||||||
disabled={quota.disabled}
|
[config.id]: false,
|
||||||
aria-disabled={quota.disabled}
|
}));
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
}}
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
>
|
||||||
>
|
<Github className="size-4" />
|
||||||
<Plus className="size-3.5" />
|
{t('plugins.installFromGithub')}
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuContent>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenu>
|
||||||
<DropdownMenuItem
|
) : isSkill ? (
|
||||||
onClick={(e) => {
|
<DropdownMenu>
|
||||||
e.stopPropagation();
|
<DropdownMenuTrigger asChild>
|
||||||
navigate('/home/skills?action=create');
|
<button
|
||||||
setPopoverOpen((prev) => ({
|
type="button"
|
||||||
...prev,
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
[config.id]: false,
|
>
|
||||||
}));
|
<Plus className="size-3.5" />
|
||||||
}}
|
</button>
|
||||||
>
|
</DropdownMenuTrigger>
|
||||||
<FilePlus2 className="size-4" />
|
<DropdownMenuContent align="end">
|
||||||
{t('skills.createManually')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/skills?action=create');
|
||||||
e.stopPropagation();
|
setPopoverOpen((prev) => ({
|
||||||
navigate('/home/add-extension?manual=1');
|
...prev,
|
||||||
setPopoverOpen((prev) => ({
|
[config.id]: false,
|
||||||
...prev,
|
}));
|
||||||
[config.id]: false,
|
}}
|
||||||
}));
|
>
|
||||||
}}
|
<FilePlus2 className="size-4" />
|
||||||
>
|
{t('skills.createManually')}
|
||||||
<Upload className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.uploadZip')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/add-extension?manual=1');
|
||||||
e.stopPropagation();
|
setPopoverOpen((prev) => ({
|
||||||
navigate('/home/add-extension?manual=1');
|
...prev,
|
||||||
setPopoverOpen((prev) => ({
|
[config.id]: false,
|
||||||
...prev,
|
}));
|
||||||
[config.id]: false,
|
}}
|
||||||
}));
|
>
|
||||||
}}
|
<Upload className="size-4" />
|
||||||
>
|
{t('skills.uploadZip')}
|
||||||
<Github className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.importFromGithub')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
</DropdownMenuContent>
|
e.stopPropagation();
|
||||||
</DropdownMenu>
|
navigate('/home/add-extension?manual=1');
|
||||||
) : (
|
setPopoverOpen((prev) => ({
|
||||||
<button
|
...prev,
|
||||||
type="button"
|
[config.id]: false,
|
||||||
disabled={quota.disabled}
|
}));
|
||||||
aria-disabled={quota.disabled}
|
}}
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
>
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
<Github className="size-4" />
|
||||||
onClick={() => {
|
{t('skills.importFromGithub')}
|
||||||
navigate(`${routePrefix}?id=new`);
|
</DropdownMenuItem>
|
||||||
setPopoverOpen((prev) => ({
|
</DropdownMenuContent>
|
||||||
...prev,
|
</DropdownMenu>
|
||||||
[config.id]: false,
|
) : (
|
||||||
}));
|
<button
|
||||||
}}
|
type="button"
|
||||||
>
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
<Plus className="size-3.5" />
|
onClick={() => {
|
||||||
</button>
|
navigate(`${routePrefix}?id=new`);
|
||||||
)}
|
setPopoverOpen((prev) => ({
|
||||||
</WorkspaceQuotaTooltip>
|
...prev,
|
||||||
)}
|
[config.id]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||||
{renderEntityList(true)}
|
{renderEntityList(true)}
|
||||||
@@ -1138,119 +1096,103 @@ function NavItems({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canCreate && (
|
{canCreate &&
|
||||||
<WorkspaceQuotaTooltip
|
(isPlugin ? (
|
||||||
quota={quota}
|
<DropdownMenu>
|
||||||
resource={config.name}
|
<DropdownMenuTrigger asChild>
|
||||||
side="right"
|
<button
|
||||||
>
|
type="button"
|
||||||
{isPlugin ? (
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||||
<DropdownMenu>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<DropdownMenuTrigger asChild>
|
>
|
||||||
<button
|
<Plus className="size-3.5" />
|
||||||
type="button"
|
</button>
|
||||||
disabled={quota.disabled}
|
</DropdownMenuTrigger>
|
||||||
aria-disabled={quota.disabled}
|
<DropdownMenuContent align="end">
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
{systemInfo.enable_marketplace && (
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{systemInfo.enable_marketplace && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate('/home/add-extension');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Store className="size-4" />
|
|
||||||
{t('plugins.goToMarketplace')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate('/home/add-extension?manual=1');
|
navigate('/home/add-extension');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Store className="size-4" />
|
||||||
{t('plugins.uploadLocal')}
|
{t('plugins.goToMarketplace')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
)}
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate('/home/add-extension?manual=1');
|
e.stopPropagation();
|
||||||
}}
|
navigate('/home/add-extension?manual=1');
|
||||||
>
|
}}
|
||||||
<Github className="size-4" />
|
>
|
||||||
{t('plugins.installFromGithub')}
|
<Upload className="size-4" />
|
||||||
</DropdownMenuItem>
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
<DropdownMenuItem
|
||||||
) : isSkill ? (
|
onClick={(e) => {
|
||||||
<DropdownMenu>
|
e.stopPropagation();
|
||||||
<DropdownMenuTrigger asChild>
|
navigate('/home/add-extension?manual=1');
|
||||||
<button
|
}}
|
||||||
type="button"
|
>
|
||||||
disabled={quota.disabled}
|
<Github className="size-4" />
|
||||||
aria-disabled={quota.disabled}
|
{t('plugins.installFromGithub')}
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
</DropdownMenuItem>
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
</DropdownMenuContent>
|
||||||
onClick={(e) => e.stopPropagation()}
|
</DropdownMenu>
|
||||||
>
|
) : isSkill ? (
|
||||||
<Plus className="size-3.5" />
|
<DropdownMenu>
|
||||||
</button>
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenuTrigger>
|
<button
|
||||||
<DropdownMenuContent align="end">
|
type="button"
|
||||||
<DropdownMenuItem
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
>
|
||||||
navigate('/home/skills?action=create');
|
<Plus className="size-3.5" />
|
||||||
}}
|
</button>
|
||||||
>
|
</DropdownMenuTrigger>
|
||||||
<FilePlus2 className="size-4" />
|
<DropdownMenuContent align="end">
|
||||||
{t('skills.createManually')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/skills?action=create');
|
||||||
e.stopPropagation();
|
}}
|
||||||
navigate('/home/add-extension?manual=1');
|
>
|
||||||
}}
|
<FilePlus2 className="size-4" />
|
||||||
>
|
{t('skills.createManually')}
|
||||||
<Upload className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.uploadZip')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/add-extension?manual=1');
|
||||||
e.stopPropagation();
|
}}
|
||||||
navigate('/home/add-extension?manual=1');
|
>
|
||||||
}}
|
<Upload className="size-4" />
|
||||||
>
|
{t('skills.uploadZip')}
|
||||||
<Github className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.importFromGithub')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
</DropdownMenuContent>
|
e.stopPropagation();
|
||||||
</DropdownMenu>
|
navigate('/home/add-extension?manual=1');
|
||||||
) : (
|
}}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Github className="size-4" />
|
||||||
disabled={quota.disabled}
|
{t('skills.importFromGithub')}
|
||||||
aria-disabled={quota.disabled}
|
</DropdownMenuItem>
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
</DropdownMenuContent>
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
</DropdownMenu>
|
||||||
onClick={(e) => {
|
) : (
|
||||||
e.stopPropagation();
|
<button
|
||||||
navigate(`${routePrefix}?id=new`);
|
type="button"
|
||||||
}}
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||||
>
|
onClick={(e) => {
|
||||||
<Plus className="size-3.5" />
|
e.stopPropagation();
|
||||||
</button>
|
navigate(`${routePrefix}?id=new`);
|
||||||
)}
|
}}
|
||||||
</WorkspaceQuotaTooltip>
|
>
|
||||||
)}
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1695,13 +1637,6 @@ export default function HomeSidebar({
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
const workspaces = useWorkspaceBootstrap();
|
|
||||||
const showWorkspaceSwitcher =
|
|
||||||
workspaces.length > 1 ||
|
|
||||||
currentWorkspace?.workspace.source === 'cloud_projection';
|
|
||||||
const canViewStorageAnalysis =
|
|
||||||
currentWorkspace?.workspace.source !== 'cloud_projection' &&
|
|
||||||
currentWorkspace?.permissions.includes('audit.view');
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] =
|
const [settingsSection, setSettingsSection] =
|
||||||
useState<SettingsSection>('models');
|
useState<SettingsSection>('models');
|
||||||
@@ -1980,11 +1915,9 @@ export default function HomeSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
{showWorkspaceSwitcher && (
|
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation items grouped by section */}
|
{/* Navigation items grouped by section */}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -2165,16 +2098,15 @@ export default function HomeSidebar({
|
|||||||
<UsersRound />
|
<UsersRound />
|
||||||
{t('workspace.settings')}
|
{t('workspace.settings')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canViewStorageAnalysis && (
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={() => {
|
||||||
onClick={() => {
|
setUserMenuOpen(false);
|
||||||
setUserMenuOpen(false);
|
openSettings('storageAnalysis');
|
||||||
openSettings('storageAnalysis');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<HardDrive />
|
||||||
{t('storageAnalysis.title')}
|
{t('storageAnalysis.title')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
@@ -49,11 +48,9 @@ export interface SidebarDataContextValue {
|
|||||||
pipelines: SidebarEntityItem[];
|
pipelines: SidebarEntityItem[];
|
||||||
knowledgeBases: SidebarEntityItem[];
|
knowledgeBases: SidebarEntityItem[];
|
||||||
plugins: SidebarEntityItem[];
|
plugins: SidebarEntityItem[];
|
||||||
pluginCount: number;
|
|
||||||
mcpServers: SidebarEntityItem[];
|
mcpServers: SidebarEntityItem[];
|
||||||
skills: SidebarEntityItem[];
|
skills: SidebarEntityItem[];
|
||||||
pluginPages: PluginPageItem[];
|
pluginPages: PluginPageItem[];
|
||||||
quotaDataLoaded: boolean;
|
|
||||||
refreshBots: () => Promise<void>;
|
refreshBots: () => Promise<void>;
|
||||||
refreshPipelines: () => Promise<void>;
|
refreshPipelines: () => Promise<void>;
|
||||||
refreshKnowledgeBases: () => Promise<void>;
|
refreshKnowledgeBases: () => Promise<void>;
|
||||||
@@ -80,36 +77,9 @@ export function SidebarDataProvider({
|
|||||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||||
const [pluginCount, setPluginCount] = useState(0);
|
|
||||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||||
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
|
|
||||||
const refreshRequestIds = useRef({
|
|
||||||
bots: 0,
|
|
||||||
pipelines: 0,
|
|
||||||
knowledgeBases: 0,
|
|
||||||
plugins: 0,
|
|
||||||
mcpServers: 0,
|
|
||||||
skills: 0,
|
|
||||||
});
|
|
||||||
const quotaResourceLoaded = useRef({
|
|
||||||
bots: false,
|
|
||||||
pipelines: false,
|
|
||||||
knowledgeBases: false,
|
|
||||||
plugins: false,
|
|
||||||
mcpServers: false,
|
|
||||||
skills: false,
|
|
||||||
});
|
|
||||||
const setQuotaResourceLoaded = useCallback(
|
|
||||||
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
|
|
||||||
quotaResourceLoaded.current[resource] = loaded;
|
|
||||||
setQuotaDataLoaded(
|
|
||||||
Object.values(quotaResourceLoaded.current).every(Boolean),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||||
useState<boolean>(() => {
|
useState<boolean>(() => {
|
||||||
@@ -126,11 +96,8 @@ export function SidebarDataProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshBots = useCallback(async () => {
|
const refreshBots = useCallback(async () => {
|
||||||
const requestId = ++refreshRequestIds.current.bots;
|
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getBots();
|
const resp = await httpClient.getBots();
|
||||||
if (requestId !== refreshRequestIds.current.bots) return;
|
|
||||||
setQuotaResourceLoaded('bots', true);
|
|
||||||
setBots(
|
setBots(
|
||||||
resp.bots.map((bot) => ({
|
resp.bots.map((bot) => ({
|
||||||
id: bot.uuid || '',
|
id: bot.uuid || '',
|
||||||
@@ -142,18 +109,13 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== refreshRequestIds.current.bots) return;
|
|
||||||
setQuotaResourceLoaded('bots', false);
|
|
||||||
console.error('Failed to fetch bots for sidebar:', error);
|
console.error('Failed to fetch bots for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, [setQuotaResourceLoaded]);
|
}, []);
|
||||||
|
|
||||||
const refreshPipelines = useCallback(async () => {
|
const refreshPipelines = useCallback(async () => {
|
||||||
const requestId = ++refreshRequestIds.current.pipelines;
|
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getPipelines();
|
const resp = await httpClient.getPipelines();
|
||||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
|
||||||
setQuotaResourceLoaded('pipelines', true);
|
|
||||||
setPipelines(
|
setPipelines(
|
||||||
resp.pipelines.map((p) => ({
|
resp.pipelines.map((p) => ({
|
||||||
id: p.uuid || '',
|
id: p.uuid || '',
|
||||||
@@ -164,18 +126,13 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
|
||||||
setQuotaResourceLoaded('pipelines', false);
|
|
||||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, [setQuotaResourceLoaded]);
|
}, []);
|
||||||
|
|
||||||
const refreshKnowledgeBases = useCallback(async () => {
|
const refreshKnowledgeBases = useCallback(async () => {
|
||||||
const requestId = ++refreshRequestIds.current.knowledgeBases;
|
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getKnowledgeBases();
|
const resp = await httpClient.getKnowledgeBases();
|
||||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
|
||||||
setQuotaResourceLoaded('knowledgeBases', true);
|
|
||||||
setKnowledgeBases(
|
setKnowledgeBases(
|
||||||
resp.bases.map((kb) => ({
|
resp.bases.map((kb) => ({
|
||||||
id: kb.uuid || '',
|
id: kb.uuid || '',
|
||||||
@@ -186,14 +143,11 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
|
||||||
setQuotaResourceLoaded('knowledgeBases', false);
|
|
||||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, [setQuotaResourceLoaded]);
|
}, []);
|
||||||
|
|
||||||
const refreshPlugins = useCallback(async () => {
|
const refreshPlugins = useCallback(async () => {
|
||||||
const requestId = ++refreshRequestIds.current.plugins;
|
|
||||||
try {
|
try {
|
||||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||||
httpClient.getPlugins(),
|
httpClient.getPlugins(),
|
||||||
@@ -201,9 +155,6 @@ export function SidebarDataProvider({
|
|||||||
.getMarketplacePlugins(1, 100)
|
.getMarketplacePlugins(1, 100)
|
||||||
.catch(() => ({ plugins: [] })),
|
.catch(() => ({ plugins: [] })),
|
||||||
]);
|
]);
|
||||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
|
||||||
setQuotaResourceLoaded('plugins', true);
|
|
||||||
setPluginCount(pluginsResp.plugins?.length ?? 0);
|
|
||||||
|
|
||||||
// Build marketplace version lookup: "author/name" -> latest_version
|
// Build marketplace version lookup: "author/name" -> latest_version
|
||||||
const marketplaceVersions = new Map<string, string>();
|
const marketplaceVersions = new Map<string, string>();
|
||||||
@@ -290,18 +241,13 @@ export function SidebarDataProvider({
|
|||||||
}
|
}
|
||||||
setPluginPages(pages);
|
setPluginPages(pages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
|
||||||
setQuotaResourceLoaded('plugins', false);
|
|
||||||
console.error('Failed to fetch plugins for sidebar:', error);
|
console.error('Failed to fetch plugins for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, [setQuotaResourceLoaded]);
|
}, []);
|
||||||
|
|
||||||
const refreshMCPServers = useCallback(async () => {
|
const refreshMCPServers = useCallback(async () => {
|
||||||
const requestId = ++refreshRequestIds.current.mcpServers;
|
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getMCPServers();
|
const resp = await httpClient.getMCPServers();
|
||||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
|
||||||
setQuotaResourceLoaded('mcpServers', true);
|
|
||||||
setMCPServers(
|
setMCPServers(
|
||||||
resp.servers.map((server) => ({
|
resp.servers.map((server) => ({
|
||||||
id: server.name, // Keep __ for API calls
|
id: server.name, // Keep __ for API calls
|
||||||
@@ -311,18 +257,13 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
|
||||||
setQuotaResourceLoaded('mcpServers', false);
|
|
||||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, [setQuotaResourceLoaded]);
|
}, []);
|
||||||
|
|
||||||
const refreshSkills = useCallback(async () => {
|
const refreshSkills = useCallback(async () => {
|
||||||
const requestId = ++refreshRequestIds.current.skills;
|
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getSkills();
|
const resp = await httpClient.getSkills();
|
||||||
if (requestId !== refreshRequestIds.current.skills) return;
|
|
||||||
setQuotaResourceLoaded('skills', true);
|
|
||||||
setSkills(
|
setSkills(
|
||||||
resp.skills.map((skill) => ({
|
resp.skills.map((skill) => ({
|
||||||
id: skill.name,
|
id: skill.name,
|
||||||
@@ -332,22 +273,11 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== refreshRequestIds.current.skills) return;
|
|
||||||
setQuotaResourceLoaded('skills', false);
|
|
||||||
console.error('Failed to fetch skills for sidebar:', error);
|
console.error('Failed to fetch skills for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, [setQuotaResourceLoaded]);
|
}, []);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
quotaResourceLoaded.current = {
|
|
||||||
bots: false,
|
|
||||||
pipelines: false,
|
|
||||||
knowledgeBases: false,
|
|
||||||
plugins: false,
|
|
||||||
mcpServers: false,
|
|
||||||
skills: false,
|
|
||||||
};
|
|
||||||
setQuotaDataLoaded(false);
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshBots(),
|
refreshBots(),
|
||||||
refreshPipelines(),
|
refreshPipelines(),
|
||||||
@@ -377,11 +307,9 @@ export function SidebarDataProvider({
|
|||||||
pipelines,
|
pipelines,
|
||||||
knowledgeBases,
|
knowledgeBases,
|
||||||
plugins,
|
plugins,
|
||||||
pluginCount,
|
|
||||||
mcpServers,
|
mcpServers,
|
||||||
skills,
|
skills,
|
||||||
pluginPages,
|
pluginPages,
|
||||||
quotaDataLoaded,
|
|
||||||
refreshBots,
|
refreshBots,
|
||||||
refreshPipelines,
|
refreshPipelines,
|
||||||
refreshKnowledgeBases,
|
refreshKnowledgeBases,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Boxes } from 'lucide-react';
|
import { Plus, Boxes } from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { ModelProvider } from '@/app/infra/entities/api';
|
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -15,6 +15,7 @@ import ProviderForm from './component/provider-form/ProviderForm';
|
|||||||
import { ProviderCard } from './components';
|
import { ProviderCard } from './components';
|
||||||
import {
|
import {
|
||||||
ExtraArg,
|
ExtraArg,
|
||||||
|
DEFAULT_REASONING_CONFIG,
|
||||||
ModelType,
|
ModelType,
|
||||||
ScanModelsResult,
|
ScanModelsResult,
|
||||||
SelectedScannedModel,
|
SelectedScannedModel,
|
||||||
@@ -285,6 +286,7 @@ export default function ModelsPanel({
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) {
|
) {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -300,6 +302,7 @@ export default function ModelsPanel({
|
|||||||
name,
|
name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
abilities,
|
abilities,
|
||||||
|
reasoning_config: reasoningConfig,
|
||||||
context_length: parseContextLength(
|
context_length: parseContextLength(
|
||||||
contextLength,
|
contextLength,
|
||||||
t('models.contextLengthInvalid'),
|
t('models.contextLengthInvalid'),
|
||||||
@@ -361,6 +364,7 @@ export default function ModelsPanel({
|
|||||||
name: item.model.name,
|
name: item.model.name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
abilities: item.abilities,
|
abilities: item.abilities,
|
||||||
|
reasoning_config: DEFAULT_REASONING_CONFIG,
|
||||||
context_length: item.model.context_length ?? null,
|
context_length: item.model.context_length ?? null,
|
||||||
extra_args: {},
|
extra_args: {},
|
||||||
} as never);
|
} as never);
|
||||||
@@ -398,6 +402,7 @@ export default function ModelsPanel({
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) {
|
) {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -413,6 +418,7 @@ export default function ModelsPanel({
|
|||||||
name,
|
name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
abilities,
|
abilities,
|
||||||
|
reasoning_config: reasoningConfig,
|
||||||
context_length: parseContextLength(
|
context_length: parseContextLength(
|
||||||
contextLength,
|
contextLength,
|
||||||
t('models.contextLengthInvalid'),
|
t('models.contextLengthInvalid'),
|
||||||
@@ -469,6 +475,7 @@ export default function ModelsPanel({
|
|||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
) {
|
) {
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
@@ -491,6 +498,7 @@ export default function ModelsPanel({
|
|||||||
provider_uuid: '',
|
provider_uuid: '',
|
||||||
provider: providerData,
|
provider: providerData,
|
||||||
abilities,
|
abilities,
|
||||||
|
reasoning_config: reasoningConfig,
|
||||||
extra_args: extraArgsObj,
|
extra_args: extraArgsObj,
|
||||||
} as never);
|
} as never);
|
||||||
} else if (modelType === 'embedding') {
|
} else if (modelType === 'embedding') {
|
||||||
@@ -554,13 +562,21 @@ export default function ModelsPanel({
|
|||||||
onSpaceLogin={handleSpaceLogin}
|
onSpaceLogin={handleSpaceLogin}
|
||||||
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
|
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
|
||||||
onCloseAddModel={() => setAddModelPopoverOpen(null)}
|
onCloseAddModel={() => setAddModelPopoverOpen(null)}
|
||||||
onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
|
onAddModel={(
|
||||||
|
modelType,
|
||||||
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
contextLength,
|
||||||
|
) =>
|
||||||
handleAddModel(
|
handleAddModel(
|
||||||
provider.uuid,
|
provider.uuid,
|
||||||
modelType,
|
modelType,
|
||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
contextLength,
|
contextLength,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -576,6 +592,7 @@ export default function ModelsPanel({
|
|||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
contextLength,
|
contextLength,
|
||||||
) =>
|
) =>
|
||||||
handleUpdateModel(
|
handleUpdateModel(
|
||||||
@@ -585,6 +602,7 @@ export default function ModelsPanel({
|
|||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
contextLength,
|
contextLength,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -593,8 +611,15 @@ export default function ModelsPanel({
|
|||||||
onDeleteModel={(modelId, modelType) =>
|
onDeleteModel={(modelId, modelType) =>
|
||||||
handleDeleteModel(provider.uuid, modelId, modelType)
|
handleDeleteModel(provider.uuid, modelId, modelType)
|
||||||
}
|
}
|
||||||
onTestModel={(name, modelType, abilities, extraArgs) =>
|
onTestModel={(name, modelType, abilities, extraArgs, reasoningConfig) =>
|
||||||
handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)
|
handleTestModel(
|
||||||
|
provider.uuid,
|
||||||
|
name,
|
||||||
|
modelType,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Eye,
|
Eye,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
BrainCircuit,
|
||||||
Check,
|
Check,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -20,8 +21,12 @@ import {
|
|||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
|
||||||
import {
|
import {
|
||||||
|
ReasoningConfig,
|
||||||
|
ScannedProviderModel,
|
||||||
|
} from '@/app/infra/entities/api';
|
||||||
|
import {
|
||||||
|
DEFAULT_REASONING_CONFIG,
|
||||||
ExtraArg,
|
ExtraArg,
|
||||||
ModelType,
|
ModelType,
|
||||||
ScanModelsResult,
|
ScanModelsResult,
|
||||||
@@ -42,6 +47,7 @@ interface AddModelPopoverProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
@@ -54,6 +60,7 @@ interface AddModelPopoverProps {
|
|||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
@@ -143,11 +150,24 @@ export default function AddModelPopover({
|
|||||||
tab === 'llm' && contextLength.trim()
|
tab === 'llm' && contextLength.trim()
|
||||||
? Number(contextLength.trim())
|
? Number(contextLength.trim())
|
||||||
: null;
|
: null;
|
||||||
await onAddModel(tab, name, abilities, extraArgs, parsedContextLength);
|
await onAddModel(
|
||||||
|
tab,
|
||||||
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
DEFAULT_REASONING_CONFIG,
|
||||||
|
parsedContextLength,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs);
|
await onTestModel(
|
||||||
|
name,
|
||||||
|
tab,
|
||||||
|
tab === 'llm' ? abilities : [],
|
||||||
|
extraArgs,
|
||||||
|
DEFAULT_REASONING_CONFIG,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
@@ -322,7 +342,7 @@ export default function AddModelPopover({
|
|||||||
{tab === 'llm' && (
|
{tab === 'llm' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('models.abilities')}</Label>
|
<Label>{t('models.abilities')}</Label>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="add-vision"
|
id="add-vision"
|
||||||
@@ -349,6 +369,19 @@ export default function AddModelPopover({
|
|||||||
{t('models.functionCallAbility')}
|
{t('models.functionCallAbility')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="add-reasoning"
|
||||||
|
checked={abilities.includes('reasoning')}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleAbility('reasoning', checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="add-reasoning" className="text-sm">
|
||||||
|
<BrainCircuit className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.reasoningAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
|
import { Trash2, Eye, Wrench, Check, BrainCircuit } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -11,8 +11,17 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LLMModel, EmbeddingModel } from '@/app/infra/entities/api';
|
import {
|
||||||
import { ExtraArg, ModelType, TestResult } from '../types';
|
LLMModel,
|
||||||
|
EmbeddingModel,
|
||||||
|
ReasoningConfig,
|
||||||
|
} from '@/app/infra/entities/api';
|
||||||
|
import {
|
||||||
|
DEFAULT_REASONING_CONFIG,
|
||||||
|
ExtraArg,
|
||||||
|
ModelType,
|
||||||
|
TestResult,
|
||||||
|
} from '../types';
|
||||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||||
import { userInfo } from '@/app/infra/http';
|
import { userInfo } from '@/app/infra/http';
|
||||||
|
|
||||||
@@ -32,12 +41,14 @@ interface ModelItemProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onTestModel: (
|
onTestModel: (
|
||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
@@ -103,7 +114,6 @@ export default function ModelItem({
|
|||||||
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
|
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
|
||||||
convertExtraArgsToArray(model.extra_args),
|
convertExtraArgsToArray(model.extra_args),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEditOpen = editModelPopoverOpen === model.uuid;
|
const isEditOpen = editModelPopoverOpen === model.uuid;
|
||||||
const isDeleteOpen = deleteConfirmOpen === model.uuid;
|
const isDeleteOpen = deleteConfirmOpen === model.uuid;
|
||||||
|
|
||||||
@@ -133,12 +143,20 @@ export default function ModelItem({
|
|||||||
editName,
|
editName,
|
||||||
editAbilities,
|
editAbilities,
|
||||||
editExtraArgs,
|
editExtraArgs,
|
||||||
|
modelType === 'llm'
|
||||||
|
? (model as LLMModel).reasoning_config || DEFAULT_REASONING_CONFIG
|
||||||
|
: DEFAULT_REASONING_CONFIG,
|
||||||
parsedContextLength,
|
parsedContextLength,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
await onTestModel(editName, editAbilities, editExtraArgs);
|
await onTestModel(
|
||||||
|
editName,
|
||||||
|
editAbilities,
|
||||||
|
editExtraArgs,
|
||||||
|
DEFAULT_REASONING_CONFIG,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAbility = (ability: string, checked: boolean) => {
|
const toggleAbility = (ability: string, checked: boolean) => {
|
||||||
@@ -149,6 +167,12 @@ export default function ModelItem({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const supportsReasoning =
|
||||||
|
modelType === 'llm' &&
|
||||||
|
((model as LLMModel).reasoning_capabilities?.supported === true ||
|
||||||
|
(model as LLMModel).abilities?.includes('reasoning'));
|
||||||
|
const canSaveModel = !isLangBotModels;
|
||||||
|
|
||||||
// Check if popover should be disabled (space models when not logged in)
|
// Check if popover should be disabled (space models when not logged in)
|
||||||
const isPopoverDisabled =
|
const isPopoverDisabled =
|
||||||
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
|
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
|
||||||
@@ -194,6 +218,12 @@ export default function ModelItem({
|
|||||||
<Wrench className="h-3 w-3" />
|
<Wrench className="h-3 w-3" />
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{supportsReasoning && (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1">
|
||||||
|
<BrainCircuit className="h-3 w-3" />
|
||||||
|
{t('models.reasoningAbility')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canManage && !isLangBotModels && (
|
{canManage && !isLangBotModels && (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -270,7 +300,7 @@ export default function ModelItem({
|
|||||||
{modelType === 'llm' && (
|
{modelType === 'llm' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('models.abilities')}</Label>
|
<Label>{t('models.abilities')}</Label>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`edit-vision-${model.uuid}`}
|
id={`edit-vision-${model.uuid}`}
|
||||||
@@ -305,6 +335,23 @@ export default function ModelItem({
|
|||||||
{t('models.functionCallAbility')}
|
{t('models.functionCallAbility')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`edit-reasoning-${model.uuid}`}
|
||||||
|
checked={editAbilities.includes('reasoning')}
|
||||||
|
disabled={isLangBotModels}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleAbility('reasoning', checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`edit-reasoning-${model.uuid}`}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<BrainCircuit className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.reasoningAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,7 +383,7 @@ export default function ModelItem({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isLangBotModels && (
|
{canSaveModel && (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -347,7 +394,7 @@ export default function ModelItem({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
className={isLangBotModels ? 'w-full' : 'flex-1'}
|
className={canSaveModel ? 'flex-1' : 'w-full'}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleTest}
|
onClick={handleTest}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Radar,
|
Radar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { ModelProvider } from '@/app/infra/entities/api';
|
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -63,6 +63,7 @@ interface ProviderCardProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
@@ -78,6 +79,7 @@ interface ProviderCardProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onOpenDeleteConfirm: (modelId: string) => void;
|
onOpenDeleteConfirm: (modelId: string) => void;
|
||||||
@@ -88,6 +90,7 @@ interface ProviderCardProps {
|
|||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
@@ -218,22 +221,20 @@ export default function ProviderCard({
|
|||||||
<span>
|
<span>
|
||||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||||
</span>
|
</span>
|
||||||
{isWorkspaceOwner && (
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-5 w-5"
|
||||||
className="h-5 w-5"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
window.open(
|
||||||
window.open(
|
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
'_blank',
|
||||||
'_blank',
|
);
|
||||||
);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Plus className="h-3 w-3" />
|
||||||
<Plus className="h-3 w-3" />
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||||
@@ -432,6 +433,7 @@ export default function ProviderCard({
|
|||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
contextLength,
|
contextLength,
|
||||||
) =>
|
) =>
|
||||||
onUpdateModel(
|
onUpdateModel(
|
||||||
@@ -440,11 +442,23 @@ export default function ProviderCard({
|
|||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
contextLength,
|
contextLength,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onTestModel={(name, abilities, extraArgs) =>
|
onTestModel={(
|
||||||
onTestModel(name, 'llm', abilities, extraArgs)
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
) =>
|
||||||
|
onTestModel(
|
||||||
|
name,
|
||||||
|
'llm',
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
@@ -466,17 +480,34 @@ export default function ProviderCard({
|
|||||||
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
||||||
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
||||||
onDeleteModel={() => onDeleteModel(model.uuid, 'embedding')}
|
onDeleteModel={() => onDeleteModel(model.uuid, 'embedding')}
|
||||||
onUpdateModel={(name, abilities, extraArgs) =>
|
onUpdateModel={(
|
||||||
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
) =>
|
||||||
onUpdateModel(
|
onUpdateModel(
|
||||||
model.uuid,
|
model.uuid,
|
||||||
'embedding',
|
'embedding',
|
||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onTestModel={(name, abilities, extraArgs) =>
|
onTestModel={(
|
||||||
onTestModel(name, 'embedding', abilities, extraArgs)
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
) =>
|
||||||
|
onTestModel(
|
||||||
|
name,
|
||||||
|
'embedding',
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
@@ -498,17 +529,34 @@ export default function ProviderCard({
|
|||||||
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
||||||
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
||||||
onDeleteModel={() => onDeleteModel(model.uuid, 'rerank')}
|
onDeleteModel={() => onDeleteModel(model.uuid, 'rerank')}
|
||||||
onUpdateModel={(name, abilities, extraArgs) =>
|
onUpdateModel={(
|
||||||
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
) =>
|
||||||
onUpdateModel(
|
onUpdateModel(
|
||||||
model.uuid,
|
model.uuid,
|
||||||
'rerank',
|
'rerank',
|
||||||
name,
|
name,
|
||||||
abilities,
|
abilities,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onTestModel={(name, abilities, extraArgs) =>
|
onTestModel={(
|
||||||
onTestModel(name, 'rerank', abilities, extraArgs)
|
name,
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
) =>
|
||||||
|
onTestModel(
|
||||||
|
name,
|
||||||
|
'rerank',
|
||||||
|
abilities,
|
||||||
|
extraArgs,
|
||||||
|
reasoningConfig,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ModelProvider,
|
ModelProvider,
|
||||||
ProviderScanDebugInfo,
|
ProviderScanDebugInfo,
|
||||||
ScannedProviderModel,
|
ScannedProviderModel,
|
||||||
|
ReasoningConfig,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
|
|
||||||
export type ExtraArg = {
|
export type ExtraArg = {
|
||||||
@@ -16,6 +17,10 @@ export type ExtraArg = {
|
|||||||
|
|
||||||
export type ModelType = 'llm' | 'embedding' | 'rerank';
|
export type ModelType = 'llm' | 'embedding' | 'rerank';
|
||||||
|
|
||||||
|
export const DEFAULT_REASONING_CONFIG: ReasoningConfig = {
|
||||||
|
level: 'provider_default',
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProviderModels {
|
export interface ProviderModels {
|
||||||
llm: LLMModel[];
|
llm: LLMModel[];
|
||||||
embedding: EmbeddingModel[];
|
embedding: EmbeddingModel[];
|
||||||
@@ -53,12 +58,14 @@ export interface ModelItemProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onTest: (
|
onTest: (
|
||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
@@ -90,6 +97,7 @@ export interface ProviderCardProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
@@ -105,6 +113,7 @@ export interface ProviderCardProps {
|
|||||||
name: string,
|
name: string,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
contextLength?: number | null,
|
contextLength?: number | null,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onOpenDeleteConfirm: (modelId: string) => void;
|
onOpenDeleteConfirm: (modelId: string) => void;
|
||||||
@@ -115,6 +124,7 @@ export interface ProviderCardProps {
|
|||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
|
reasoningConfig: ReasoningConfig,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ReasoningLevel } from '@/app/infra/entities/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
|
||||||
|
export const REASONING_LEVELS: ReasoningLevel[] = [
|
||||||
|
'provider_default',
|
||||||
|
'disabled',
|
||||||
|
'enabled',
|
||||||
|
'minimal',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
'max',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const REASONING_LEVEL_LABEL_KEYS: Record<ReasoningLevel, string> = {
|
||||||
|
provider_default: 'models.reasoningLevels.providerDefault',
|
||||||
|
disabled: 'models.reasoningLevels.disabled',
|
||||||
|
enabled: 'models.reasoningLevels.enabled',
|
||||||
|
minimal: 'models.reasoningLevels.minimal',
|
||||||
|
low: 'models.reasoningLevels.low',
|
||||||
|
medium: 'models.reasoningLevels.medium',
|
||||||
|
high: 'models.reasoningLevels.high',
|
||||||
|
xhigh: 'models.reasoningLevels.xhigh',
|
||||||
|
max: 'models.reasoningLevels.max',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReasoningLevelPickerProps {
|
||||||
|
value: ReasoningLevel;
|
||||||
|
levels: ReasoningLevel[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: ReasoningLevel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReasoningLevelPicker({
|
||||||
|
value,
|
||||||
|
levels,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: ReasoningLevelPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const safeLevels: ReasoningLevel[] =
|
||||||
|
levels.length > 0 ? levels : ['provider_default'];
|
||||||
|
const safeValue: ReasoningLevel = safeLevels.includes(value)
|
||||||
|
? value
|
||||||
|
: safeLevels[0];
|
||||||
|
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
|
||||||
|
const isExplicit = safeValue !== 'provider_default';
|
||||||
|
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || safeLevels.length <= 1}
|
||||||
|
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
|
||||||
|
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
|
||||||
|
>
|
||||||
|
<BrainCircuit
|
||||||
|
className={`size-4 shrink-0 ${isExplicit ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<span className="hidden min-w-0 truncate sm:block">
|
||||||
|
{currentLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="hidden size-3.5 shrink-0 text-muted-foreground sm:block" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-[272px] p-4">
|
||||||
|
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
|
||||||
|
<span>{currentLabel}</span>
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
className="mt-5"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(0, safeLevels.length - 1)}
|
||||||
|
step={1}
|
||||||
|
value={[currentIndex]}
|
||||||
|
aria-label={t('models.reasoningLevel')}
|
||||||
|
aria-valuetext={currentLabel}
|
||||||
|
onValueChange={([index]) => onChange(safeLevels[index])}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -133,14 +133,12 @@ export default function SettingsDialog({
|
|||||||
const permissions = currentWorkspace?.permissions ?? [];
|
const permissions = currentWorkspace?.permissions ?? [];
|
||||||
const canManageApiKeys = permissions.includes('api_key.manage');
|
const canManageApiKeys = permissions.includes('api_key.manage');
|
||||||
const canViewAudit = permissions.includes('audit.view');
|
const canViewAudit = permissions.includes('audit.view');
|
||||||
const canViewStorageAnalysis =
|
|
||||||
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
|
|
||||||
const navItems = allNavItems.filter((item) => {
|
const navItems = allNavItems.filter((item) => {
|
||||||
if (item.id === 'apiIntegration') {
|
if (item.id === 'apiIntegration') {
|
||||||
return canManageApiKeys;
|
return canManageApiKeys;
|
||||||
}
|
}
|
||||||
if (item.id === 'storageAnalysis') {
|
if (item.id === 'storageAnalysis') {
|
||||||
return canViewStorageAnalysis;
|
return canViewAudit;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -148,17 +146,11 @@ export default function SettingsDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const forbiddenSection =
|
const forbiddenSection =
|
||||||
(section === 'apiIntegration' && !canManageApiKeys) ||
|
(section === 'apiIntegration' && !canManageApiKeys) ||
|
||||||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
|
(section === 'storageAnalysis' && !canViewAudit);
|
||||||
if (open && forbiddenSection) {
|
if (open && forbiddenSection) {
|
||||||
onSectionChange('workspace');
|
onSectionChange('workspace');
|
||||||
}
|
}
|
||||||
}, [
|
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
|
||||||
canManageApiKeys,
|
|
||||||
canViewStorageAnalysis,
|
|
||||||
open,
|
|
||||||
section,
|
|
||||||
onSectionChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const activeItem = navItems.find((item) => item.id === section);
|
const activeItem = navItems.find((item) => item.id === section);
|
||||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||||
@@ -264,7 +256,7 @@ export default function SettingsDialog({
|
|||||||
active={open && section === 'apiIntegration'}
|
active={open && section === 'apiIntegration'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === 'storageAnalysis' && canViewStorageAnalysis && (
|
{section === 'storageAnalysis' && (
|
||||||
<StorageAnalysisPanel
|
<StorageAnalysisPanel
|
||||||
active={open && section === 'storageAnalysis'}
|
active={open && section === 'storageAnalysis'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
|
|
||||||
|
|
||||||
export function WorkspaceQuotaTooltip({
|
|
||||||
quota,
|
|
||||||
resource,
|
|
||||||
children,
|
|
||||||
side = 'top',
|
|
||||||
}: {
|
|
||||||
quota: WorkspaceQuotaItem;
|
|
||||||
resource: string;
|
|
||||||
children: ReactNode;
|
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!quota.disabled) return children;
|
|
||||||
const message = quota.loading
|
|
||||||
? t('limitation.quotaLoadingTooltip')
|
|
||||||
: t('limitation.createDisabledTooltip', {
|
|
||||||
resource,
|
|
||||||
max: quota.max,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
tabIndex={0}
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-label={message}
|
|
||||||
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side={side} className="max-w-72 text-left">
|
|
||||||
{message}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
|
||||||
|
|
||||||
export interface WorkspaceQuotaItem {
|
|
||||||
count: number;
|
|
||||||
max: number;
|
|
||||||
reached: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceQuotaStatus {
|
|
||||||
bots: WorkspaceQuotaItem;
|
|
||||||
pipelines: WorkspaceQuotaItem;
|
|
||||||
knowledgeBases: WorkspaceQuotaItem;
|
|
||||||
extensions: WorkspaceQuotaItem;
|
|
||||||
botsReached: boolean;
|
|
||||||
pipelinesReached: boolean;
|
|
||||||
knowledgeBasesReached: boolean;
|
|
||||||
extensionsReached: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quotaItem(
|
|
||||||
count: number,
|
|
||||||
max: number | undefined,
|
|
||||||
loaded: boolean,
|
|
||||||
): WorkspaceQuotaItem {
|
|
||||||
const normalizedMax = typeof max === 'number' ? max : -1;
|
|
||||||
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
|
|
||||||
return {
|
|
||||||
count,
|
|
||||||
max: normalizedMax,
|
|
||||||
reached,
|
|
||||||
loading: !loaded,
|
|
||||||
disabled: !loaded || reached,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
|
||||||
const {
|
|
||||||
bots,
|
|
||||||
pipelines,
|
|
||||||
knowledgeBases,
|
|
||||||
pluginCount,
|
|
||||||
mcpServers,
|
|
||||||
skills,
|
|
||||||
quotaDataLoaded,
|
|
||||||
} = useSidebarData();
|
|
||||||
const limitation = systemInfo.limitation;
|
|
||||||
|
|
||||||
const botQuota = quotaItem(
|
|
||||||
bots.length,
|
|
||||||
limitation?.max_bots,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
const pipelineQuota = quotaItem(
|
|
||||||
pipelines.length,
|
|
||||||
limitation?.max_pipelines,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
const knowledgeBaseQuota = quotaItem(
|
|
||||||
knowledgeBases.length,
|
|
||||||
limitation?.max_knowledge_bases,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
const extensionQuota = quotaItem(
|
|
||||||
pluginCount + mcpServers.length + skills.length,
|
|
||||||
limitation?.max_extensions,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
bots: botQuota,
|
|
||||||
pipelines: pipelineQuota,
|
|
||||||
knowledgeBases: knowledgeBaseQuota,
|
|
||||||
extensions: extensionQuota,
|
|
||||||
botsReached: botQuota.disabled,
|
|
||||||
pipelinesReached: pipelineQuota.disabled,
|
|
||||||
knowledgeBasesReached: knowledgeBaseQuota.disabled,
|
|
||||||
extensionsReached: extensionQuota.disabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user