1
0

50 Commits

Author SHA1 Message Date
2dec9e5c54 feat: 增强桌面启动失败提示与测试覆盖 2026-05-08 23:42:48 +08:00
c524e8f928 fix: 启动参数 duration 候选值对齐后端标准格式
前端 Select 使用 Go time.Duration.String() 标准字符串作为 value,
与后端查询/保存响应保持一致,解决保存后反显不匹配的问题。
2026-05-08 14:18:09 +08:00
6b00045f4e feat: 启动参数超时和日志保留天数改用下拉预设选择 2026-05-08 00:26:35 +08:00
e719d3c8f1 chore: 追踪 .claude/settings.json 配置文件 2026-05-07 21:16:30 +08:00
6908b9653b feat: CI check job 扩展为三平台 matrix 并行 lint/test
将 test.yml 的 check job 从单平台 ubuntu 改为 ubuntu/macos/windows 三平台并行,
Linux 额外安装 libayatana-appindicator3-dev 以支持 systray CGo 编译。
2026-05-07 17:07:55 +08:00
d8e64ef0e9 chore: 更新应用图标资源 2026-05-07 15:34:25 +08:00
fb9f6d1d00 refactor: 统计页面改名为"总览"并提升至侧边栏首位
将侧边栏"用量统计"菜单项改名为"总览",移至第一位,
默认路由重定向从 /providers 改为 /stats
2026-05-07 15:05:45 +08:00
e4c96da8a9 fix: CI 触发分支对齐仓库实际默认分支 master 2026-05-07 14:20:29 +08:00
1195e119c6 chore: 合并 dev-ci-optimize 至 master 2026-05-07 14:17:43 +08:00
4eeb14e844 feat: 新增启动参数设置页面,区分 desktop 可编辑与 server 只读 2026-05-07 14:10:56 +08:00
0d30ed9a0f feat: 新增开发 CI 流程,重构 test.yml 支持分层测试
新增 ci.yml,在 push(dev/main)和 PR 时触发快速检查(lint + 全量测试)
重构 test.yml,新增 full 参数控制是否运行 MySQL 和 E2E 测试
release.yml 调用 test.yml 时传 full: true,行为与重构前一致
同步更新 ci-test-gate spec
2026-05-07 12:43:08 +08:00
cd0b3e8fc1 feat: release CI 加入全流程测试门禁
新增独立可复用测试 workflow(test.yml),在 release 构建前串行执行
lint、默认测试、MySQL 测试和 E2E 测试,测试不通过则阻止发布构建。
2026-05-07 12:14:00 +08:00
c04a13bf8a refactor: 重写 Git hooks 体系,委托已有检查、新增模板与 LFS 校验
pre-commit 代码检查改为委托 _backend-lint / _versionctl-lint / _frontend-check,新增 LFS 指针校验;commit-msg 新增多行空行格式校验和模板注释忽略,移除 CJK/Python 字符集检测;新增 prepare-commit-msg 提交信息模板;hooks-install 增加 source 文件存在性校验;前端 check 补入 tsc -b 类型检查并修复暴露的类型错误
2026-05-06 13:44:28 +08:00
5513f0c13d feat: 区分 server 与 desktop 配置加载入口,取消自动创建配置文件
- config.go 重构:抽取 loadConfig 共享逻辑,新增 LoadServerConfig/LoadDesktopConfig/LoadDesktopConfigAtPath,LoadConfig 保持向后兼容
- setupConfigFile 移除 SafeWriteConfigAs 自动创建逻辑,文件不存在时仅使用默认值
- cmd/desktop 切换为 LoadDesktopConfig,端口/HTTP/浏览器/托盘统一使用 cfg.Server.Port
- cmd/server 显式使用 LoadServerConfig 明确入口语义
- 提取 desktop 可测 helper:desktopListenAddr/desktopURL/desktopPortMenuTitle/desktopConfigErrorMessage
- 新增测试:desktop 忽略 CLI/env/未知参数、配置快照不变、无效配置文件不静默回退、端口 helper 一致性
- README 区分 server/desktop 配置源,移除首次启动自动创建配置文件描述
- 同步 delta specs 到 openspec/specs/ 主规范
2026-05-06 11:59:19 +08:00
598e2acb7e feat: 供应商列表 Base URL、API Key 和模型列表统一模型 ID 增加一键复制按钮 2026-05-06 00:43:48 +08:00
4870d29638 fix: pre-commit Go lint 按包目录分组执行,修复测试文件 typecheck 失败
将逐文件 lint 改为按包目录去重分组,同包的 _test.go 与被测文件在同一轮
typecheck 中参与分析,避免 undefined 错误。
2026-05-05 23:52:43 +08:00
8600a39b6c fix: 发布产物自包含数据库迁移资源,修复 macOS DMG 安装后无法启动
使用 go:embed 嵌入迁移 SQL 到二进制,移除 runtime.Caller 源码路径依赖,
server 和 desktop 发布产物均可在无源码目录环境下完成数据库初始化和迁移。
2026-05-05 23:47:58 +08:00
407d008e19 chore: 版本升迁 v0.1.7 2026-05-05 22:00:04 +08:00
a2751eab31 feat: 原生 Git hooks 方案,增强版本升迁工作流 2026-05-05 21:58:30 +08:00
5655fc5560 chore: 移除 Windows arm64 构建与发布支持
Windows ARM64 使用场景极少,windows-11-arm runner 上 MSYS2
CLANGARM64 交叉编译不稳定,CGO 编译问题难以排查,维护成本
远超收益。移除 arm64 的 CI 矩阵条目、Makefile Windows 变量、
versionctl 资产白名单、README 文档和规范中的相关需求。
Linux 和 macOS arm64 不受影响。
2026-05-05 20:20:04 +08:00
49b47a1ae0 chore: 版本升迁 v0.1.6 2026-05-05 19:31:21 +08:00
bcf82d42bc fix: 修复 Windows arm64 可执行文件图标嵌入构建失败
MSYS2 CLANGARM64 环境下使用 llvm-windres,需使用 LLVM target triple
(aarch64-w64-mingw32) 而非 GNU BFD 格式 (pe-aarch64)。新增双格式变量
并增强 windres 检测逻辑,通过 --version 输出区分 GNU/LLVM 工具。
2026-05-05 19:30:22 +08:00
394025c8ea chore: 版本升迁 v0.1.5 2026-05-05 18:59:09 +08:00
34bd749741 fix: 修复发布流水线 Windows arm64 CGO 工具链和 macOS 磁盘空间问题
- Windows arm64: 在 workflow matrix 中设置 CC=clang/CXX=clang++ 并注入构建环境
- macOS: 在 Makefile 关键节点清理中间产物和临时目录释放磁盘空间
- 预检步骤改用 $CC/$CXX 替代硬编码编译器名称,与 matrix 声明保持一致
- 同步新增 release-pipeline spec 需求: Windows arm64 CGO 编译器指定
2026-05-05 18:58:50 +08:00
290f299e22 chore: 版本升迁 v0.1.4 2026-05-05 13:15:45 +08:00
859dec8ada fix: 修复 Windows arm64 发布构建 CI 失败
将 Windows arm64 构建从 x86_64 runner 上的交叉编译改为使用 windows-11-arm
原生 ARM64 runner,消除 CLANGARM64 环境在 x86_64 上的 Exec format error。
同时去掉 Linux/Windows 构建步骤中冗余的 TARGET_ARCH 显式传参,统一依赖
Makefile 中 go env GOARCH 自动检测。
2026-05-05 13:15:00 +08:00
993c0a72d6 chore: 版本升迁 v0.1.3 2026-05-05 12:38:12 +08:00
c9c3a84b33 feat: 扩展发布打包支持多组件多架构多格式产物
- 新增 web 组件独立发布为 nex-web_<version>.tar.gz
- server 新增 arm64 架构、macOS universal、Windows arm64 产物
- desktop 新增 arm64 架构支持(Linux/Windows)
- Linux desktop 新增 AppImage、deb、rpm 安装包格式
- macOS desktop 新增 unsigned DMG 安装包
- 统一发布资产命名为 {component}_{version}_{platform}_{arch}.{ext}
- 新增 SHA256SUMS 校验和清单覆盖全部发布资产
- versionctl 新增 asset-name CLI 支持按参数生成资产文件名
- Makefile release target 重构为组件/平台/架构参数化
- GitHub Actions release workflow 扩展多组件多架构构建矩阵
- 同步更新 openspec 主规范(desktop-app/release-pipeline/workspace-command-flows)
2026-05-05 12:36:33 +08:00
6de7a2d2e1 chore: 版本升迁 v0.1.2 2026-05-05 09:58:08 +08:00
6181923d8d fix: 修复发布流水线 LFS 资产校验 2026-05-05 09:57:02 +08:00
235efb0e62 chore: 版本升迁 v0.1.1 2026-05-05 04:39:31 +08:00
6b1af27ea2 fix: 移除 version-bump 的工作区干净检查 2026-05-05 04:39:21 +08:00
32f48777f3 feat: make version-bump 默认 BUMP=patch,无需显式传参 2026-05-05 04:32:38 +08:00
bc7a7c6e81 feat: 迁移 versionctl 为独立模块并新增 make version-bump 命令
- 将 backend/cmd/versionctl 和 backend/pkg/projectversion 迁移至独立 versionctl/ Go 模块
- 新增 bump 子命令支持 major/minor/patch 和指定版本号,含版本倒退防护
- 新增 make version-bump 编排完整升迁流程(bump + sync + check + commit + tag)
- 更新所有引用路径:根 Makefile、backend/Makefile、release.yml、.golangci.yml
- 新增 versionctl/.golangci.yml(精简配置)和 Makefile(lint/test/coverage)
- 根 Makefile lint/test 集成 versionctl 模块
- 同步 openspec specs:新增 version-bump spec,更新 release-pipeline spec
2026-05-05 04:18:10 +08:00
3cd0458c2c fix: 修复 golangci-lint 报告的 gosec/gocyclo/forbidigo 问题 2026-05-05 03:35:20 +08:00
8eea30ea11 feat: 统一品牌标识、关于页面三卡片布局与版本诊断功能
- 统一品牌为 Nex:侧边栏、托盘 tooltip、HTML 标题、favicon (PNG 替代 SVG)
- 重构关于页面为三卡片布局(品牌/版本/链接),版本状态 Tag 绝对定位右上角
- 新增 GET /api/version 后端接口,返回 version/commit/build_time
- 新增前端版本一致性诊断:匹配/不匹配/不可判断三种状态
- 同步 delta specs 到主 specs 并归档变更
2026-05-05 03:28:22 +08:00
9e33e570af fix: 降低请求生命周期日志级别 2026-05-05 01:54:53 +08:00
7653385838 fix: 加固发布流水线运行环境
修复 Windows 发布作业在 MSYS2 环境下无法访问 Go 工具链的问题。

为三平台发布增加工具链预检并升级 release workflow 运行时兼容性,减少版本检查噪音和 CI 告警。
2026-05-05 01:27:38 +08:00
2c401f7ae6 chore: streamline workspace make workflows
Clarify product-level server and desktop commands while moving backend-only maintenance tasks into backend/Makefile. This keeps root automation focused on core flows and aligns the main OpenSpec specs with the new command boundaries.
2026-04-28 17:44:23 +08:00
a9972360c2 feat: 增加版本化构建与发布流程
引入 VERSION 作为统一版本源,避免前端、后端、桌面打包和发布资产之间的版本漂移。
新增 tag 驱动的 Draft Release 流程与版本化资产命名,使本地演进和 GitHub 发布共享同一套约束。
2026-04-28 14:20:27 +08:00
b00fa4dcee chore: 调整开源许可证为 Apache 2.0
新增根目录 Apache 2.0 许可证文件,并同步更新仓库文档与前端包元数据声明。
2026-04-27 11:24:33 +08:00
92525b39c3 chore: 将 assets 图标文件迁移到 Git LFS 2026-04-27 10:34:38 +08:00
38a2555c7b fix: Anthropic 流式编码器补全 message_start/message_delta 必填字段
跨协议流式转换时,Anthropic 客户端 Zod 校验因 SSE 事件缺少必填字段报错。
由 Anthropic encoder 层(而非 OpenAI decoder 层)负责补全协议默认值,保持权责分离。

- encodeMessageStart 补全 type/content/stop_reason/stop_sequence,usage nil 时输出零值
- encodeMessageDelta usage nil 时输出零值
- 更新相关测试覆盖新增行为
2026-04-26 23:27:34 +08:00
9622d44aac fix: 完善转换代理行为 2026-04-26 21:48:17 +08:00
155244433f fix: 完善桌面应用图标打包
统一 macOS 图标命名为 icon.icns。\n补充 Linux hicolor 图标资源。\n修复 Windows make 构建兼容性并为 exe 嵌入图标资源。\n清理旧版图标说明与不再使用的 SVG 源文件。
2026-04-26 12:24:03 +08:00
2c043c6cf7 fix: 修正 conversion 代理路径和错误边界 2026-04-25 23:12:54 +08:00
f5c82b6980 chore: 合并 dev-test-config 到 master 2026-04-24 23:23:12 +08:00
9105a36097 feat: 将"关于"从系统托盘原生对话框迁移到前端页面
移除系统托盘右键菜单中的"关于"选项及各平台原生对话框实现,
在前端新增 /about 路由和关于页面展示品牌信息,侧边栏增加关于导航入口
2026-04-24 23:17:22 +08:00
f1ee646ca4 fix: 修复 TestSaveAndLoadConfig 测试隔离问题,使用临时目录替代真实用户配置 2026-04-24 22:59:26 +08:00
b9b487c591 chore: 统一项目编辑器配置,移至仓库根目录 2026-04-24 22:28:01 +08:00
180 changed files with 12876 additions and 1559 deletions

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*]
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
* text=auto eol=lf
assets/*.png filter=lfs diff=lfs merge=lfs -text
assets/**/*.png filter=lfs diff=lfs merge=lfs -text
assets/*.icns filter=lfs diff=lfs merge=lfs -text
assets/**/*.icns filter=lfs diff=lfs merge=lfs -text
assets/*.ico filter=lfs diff=lfs merge=lfs -text
assets/**/*.ico filter=lfs diff=lfs merge=lfs -text
frontend/public/*.png filter=lfs diff=lfs merge=lfs -text
frontend/public/**/*.png filter=lfs diff=lfs merge=lfs -text

14
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: CI
on:
push:
branches: [dev, master]
pull_request:
permissions:
contents: read
jobs:
check:
name: Check
uses: ./.github/workflows/test.yml

313
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,313 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: read
jobs:
prepare:
name: Prepare Release
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Verify tag and VERSION
id: version
run: |
version=$(go run ./versionctl print)
go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
test-gate:
name: Test Gate
needs: prepare
uses: ./.github/workflows/test.yml
with:
full: true
build-web:
name: Build Web Asset
needs: [prepare, test-gate]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Preflight web release toolchain
run: |
set -euo pipefail
command -v go
go version
command -v bun
bun --version
make release-assets-check
- name: Build web release asset
run: make release-assets-web
- name: Upload web release asset
uses: actions/upload-artifact@v4
with:
name: release-web
path: build/release/*
if-no-files-found: error
build-linux:
name: Build Linux ${{ matrix.arch }} Assets
needs: [prepare, test-gate]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Linux desktop and package dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl file libayatana-appindicator3-dev libgtk-3-dev rpm
- name: Preflight Linux release toolchain
run: |
set -euo pipefail
printf 'runner arch: %s\n' "$(uname -m)"
command -v go
go version
command -v bun
bun --version
command -v gcc
gcc --version
command -v pkg-config
pkg-config --modversion ayatana-appindicator3-0.1
pkg-config --modversion gtk+-3.0
command -v curl
command -v dpkg-deb
dpkg-deb --version
command -v rpmbuild
rpmbuild --version
make release-assets-check
- name: Build Linux release assets
run: make release-assets-linux
- name: Upload Linux release assets
uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: build/release/*
if-no-files-found: error
build-windows:
name: Build Windows ${{ matrix.arch }} Assets
needs: [prepare, test-gate]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
msystem: MINGW64
cc: gcc
cxx: g++
packages: >-
make
mingw-w64-x86_64-gcc
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup MSYS2 toolchain
uses: msys2/setup-msys2@v2
with:
msystem: ${{ matrix.msystem }}
path-type: inherit
update: true
install: ${{ matrix.packages }}
- name: Preflight Windows release toolchain
shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: |
set -euo pipefail
command -v go
go version
command -v bun
bun --version
command -v make
make --version
command -v "$CC"
"$CC" --version
command -v "$CXX"
"$CXX" --version
command -v windres
windres --version
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
else
command -v powershell
powershell -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
fi
make release-assets-check
- name: Build Windows release assets
shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: make release-assets-windows
- name: Upload Windows release assets
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: build/release/*
if-no-files-found: error
build-macos:
name: Build macOS Assets
needs: [prepare, test-gate]
runs-on: macos-15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Preflight macOS release toolchain
run: |
set -euo pipefail
printf 'runner arch: %s\n' "$(uname -m)"
command -v go
go version
command -v bun
bun --version
command -v ditto
command -v hdiutil
xcrun --find lipo
xcrun --find vtool
make release-assets-check
- name: Build macOS release assets
run: make release-assets-macos
- name: Upload macOS release assets
uses: actions/upload-artifact@v4
with:
name: release-macos
path: build/release/*
if-no-files-found: error
draft-release:
name: Create Draft Release
needs: [prepare, build-web, build-linux, build-windows, build-macos]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download release assets
uses: actions/download-artifact@v4
with:
pattern: release-*
merge-multiple: true
path: dist
- name: Generate checksums
run: make release-assets-checksums RELEASE_DIR=dist
- name: Publish draft release
uses: softprops/action-gh-release@v2
with:
name: v${{ needs.prepare.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: true
files: |
dist/*

116
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Test (Full)
on:
workflow_call:
inputs:
full:
description: "Run full test suite including MySQL and E2E"
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
check:
name: Check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libayatana-appindicator3-dev
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Lint
run: make lint
- name: Test
run: make test
mysql:
name: MySQL Tests
if: inputs.full
needs: check
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: nex_test
MYSQL_USER: nex_test
MYSQL_PASSWORD: testpass
ports:
- 13306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -ptestpass"
--health-interval=3s
--health-timeout=5s
--health-retries=10
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: MySQL tests
run: cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
e2e:
name: E2E Tests
if: inputs.full
needs: check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Playwright browsers
run: cd frontend && bunx playwright install --with-deps chromium
- name: E2E tests
run: cd frontend && bun run test:e2e

9
.gitignore vendored
View File

@@ -399,7 +399,8 @@ env/
cython_debug/
# Custom
.claude
.claude/*
!.claude/settings.json
.opencode
.codex
openspec/changes/archive
@@ -408,7 +409,11 @@ temp
skills-lock.json
.worktrees
!scripts/build/
backend/bin
backend/server
backend/desktop
# Embedfs generated
embedfs/assets/
embedfs/frontend-dist/
embedfs/frontend-dist/
backend/cmd/desktop/rsrc_windows_*.syso

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"files.eol": "\n"
}

184
LICENSE Normal file
View File

@@ -0,0 +1,184 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, "control" means (i) the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that is
included in or attached to the work (an example is provided in the Appendix
below).
"Derivative Works" shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare Derivative Works of, publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in Source or
Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License,
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and otherwise
transfer the Work, where such license applies only to those patent claims
licensable by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) with the Work
to which such Contribution(s) was submitted. If You institute patent litigation
against any entity (including a cross-claim or counterclaim in a lawsuit)
alleging that the Work or a Contribution incorporated within the Work
constitutes direct or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate as of the date
such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and in
Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of
this License; and
(b) You must cause any modified files to carry prominent notices stating that
You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from the
Source form of the Work, excluding those notices that do not pertain to any part
of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
any Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without any
additional terms or conditions. Notwithstanding the above, nothing herein shall
supersede or modify the terms of any separate license agreement you may have
executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as required
for reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any warranties
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any risks
associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in
tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to in
writing, shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or consequential damages of any character
arising as a result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill, work stoppage,
computer failure or malfunction, or any and all other commercial damages or
losses), even if such Contributor has been advised of the possibility of such
damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting such
obligations, You may act only on Your own behalf and on Your sole
responsibility, not on behalf of any other Contributor, and only if You agree to
indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets "[]" replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

695
Makefile
View File

@@ -1,253 +1,520 @@
.PHONY: all dev build test lint clean \
backend-build backend-run backend-dev backend-test backend-test-all backend-test-unit backend-test-integration backend-test-coverage \
backend-lint backend-clean backend-deps backend-generate \
backend-db-up backend-db-down backend-db-status backend-db-create \
test-mysql-up test-mysql-down test-mysql test-mysql-quick \
frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint frontend-clean \
desktop-build desktop-build-mac desktop-build-win desktop-build-linux \
desktop-dev desktop-test desktop-clean \
desktop-prepare-frontend desktop-prepare-embedfs
.PHONY: \
lint test clean hooks-install hooks-check hooks-test \
version-sync version-check version-bump \
server-run server-build server-lint server-test server-clean \
desktop-build-mac desktop-build-win desktop-build-linux \
desktop-lint desktop-test desktop-clean \
release-assets-check release-assets-web release-assets-linux release-assets-windows release-assets-macos release-assets-checksums \
release-assets-server-linux release-assets-server-windows release-assets-server-macos \
release-assets-desktop-linux release-assets-desktop-windows release-assets-desktop-macos \
_backend-lint _backend-test _backend-clean _backend-build \
_versionctl-lint _versionctl-test \
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
_hooks-pre-commit _check-clean-worktree \
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
_server-run-backend _server-run-frontend \
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
_package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm \
_package-macos-zip _package-macos-dmg
# Delay shell lookups until a target needs them, then cache the result for this make run.
lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print)
GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ")
TARGET_ARCH ?= $(call lazy_shell,_TARGET_ARCH,go env GOARCH)
GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui
RELEASE_DIR ?= build/release
LINUX_DESKTOP_BINARY = build/nex-linux-$(TARGET_ARCH)
WINDOWS_DESKTOP_BINARY = build/nex-win-$(TARGET_ARCH).exe
WINDOWS_SERVER_BINARY = build/nex-server-windows-$(TARGET_ARCH).exe
WINDRES ?= windres
ifeq ($(TARGET_ARCH),arm64)
APPIMAGE_ARCH := aarch64
DEB_ARCH := arm64
RPM_ARCH := aarch64
else
APPIMAGE_ARCH := x86_64
DEB_ARCH := amd64
RPM_ARCH := x86_64
endif
WINDOWS_WINDRES_FORMAT_BFD := pe-x86-64
WINDOWS_WINDRES_FORMAT_LLVM := x86_64-w64-mingw32
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
APPIMAGETOOL_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
# ============================================
# 顶层便捷命令
# 全局命令
# ============================================
dev:
@echo "🚀 Starting development environment..."
@$(MAKE) -j2 backend-dev frontend-dev
lint: _backend-lint _frontend-check _versionctl-lint
@printf 'Lint complete\n'
build: backend-build frontend-build
@echo "✅ Build complete"
test: _backend-test _frontend-test _desktop-test _versionctl-test
@printf 'All tests passed\n'
test: backend-test desktop-test frontend-test
@echo "✅ All tests passed"
lint: backend-lint frontend-lint
@echo "✅ Lint complete"
all: build test lint
clean: _backend-clean _frontend-clean _desktop-clean
@printf 'Clean complete\n'
# ============================================
# 后端
# Git hooks
# ============================================
backend-build:
cd backend && go build -o bin/server ./cmd/server
backend-run:
cd backend && go run ./cmd/server
backend-dev:
cd backend && go run ./cmd/server
backend-test:
cd backend && go test ./internal/... ./pkg/... ./tests/... ./cmd/server/... -v
backend-test-all:
cd backend && go test ./... -v
backend-test-unit:
cd backend && go test ./internal/... ./pkg/... -v
backend-test-integration:
cd backend && go test ./tests/... -v
backend-test-coverage:
cd backend && go test ./... -coverprofile=coverage.out
cd backend && go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: backend/coverage.html"
backend-lint:
cd backend && go tool golangci-lint run ./...
backend-clean:
rm -rf backend/bin/ backend/coverage.out backend/coverage.html
backend-deps:
cd backend && go mod tidy
backend-generate:
cd backend && go generate ./...
backend-db-up:
@echo "Running database migration up..."
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" up
backend-db-down:
@echo "Running database migration down..."
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" down
backend-db-status:
@echo "Checking database migration status..."
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" status
backend-db-create:
@read -p "Migration name: " name; \
cd backend && goose -dir migrations/sqlite create $$name sql; \
cd backend && goose -dir migrations/mysql create $$name sql
# ============================================
# MySQL 专项测试
# ============================================
test-mysql-up:
@echo "Starting MySQL test container..."
cd backend/tests/mysql && docker-compose up -d
@echo "Waiting for MySQL to be ready..."
@for i in $$(seq 1 30); do \
if docker exec nex-mysql-test mysqladmin ping -h localhost -u root -ptestpass --silent 2>/dev/null; then \
echo "MySQL is ready!"; \
exit 0; \
hooks-install:
@hooks_dir=$$(git rev-parse --git-path hooks); \
mkdir -p "$$hooks_dir"; \
for hook in pre-commit commit-msg prepare-commit-msg; do \
src="scripts/git-hooks/$$hook"; \
if [ ! -f "$$src" ]; then \
printf 'ERROR: source hook not found: %s\n' "$$src" >&2; \
exit 1; \
fi; \
echo "Waiting... ($$i/30)"; \
sleep 1; \
cp "$$src" "$$hooks_dir/$$hook"; \
chmod +x "$$hooks_dir/$$hook"; \
done; \
echo "MySQL failed to start"; \
exit 1
printf 'Installed Git hooks to %s\n' "$$hooks_dir"
test-mysql-down:
@echo "Stopping MySQL test container..."
cd backend/tests/mysql && docker-compose down -v
hooks-check:
@hooks_dir=$$(git rev-parse --git-path hooks); \
status=0; \
for hook in pre-commit commit-msg prepare-commit-msg; do \
if [ -x "$$hooks_dir/$$hook" ]; then \
printf 'OK: %s\n' "$$hook"; \
else \
printf 'MISSING: %s (%s/%s)\n' "$$hook" "$$hooks_dir" "$$hook"; \
status=1; \
fi; \
done; \
exit $$status
test-mysql: test-mysql-up
@echo "Running MySQL tests..."
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
$(MAKE) test-mysql-down
hooks-test:
@scripts/git-hooks/test-hooks.sh
test-mysql-quick:
@echo "Running MySQL tests (without container management)..."
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
_hooks-pre-commit:
@set -ef; \
staged_files=$$(git diff --cached --name-only --diff-filter=ACM); \
if [ -z "$$staged_files" ]; then \
printf 'No staged files to check\n'; \
exit 0; \
fi; \
run_backend_lint=; \
run_versionctl_lint=; \
run_frontend_check=; \
lfs_patterns=$$(grep 'filter=lfs' .gitattributes 2>/dev/null | awk '{print $$1}' || true); \
for file in $$staged_files; do \
[ -n "$$file" ] || continue; \
if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \
printf 'Found conflict markers in staged file: %s\n' "$$file" >&2; \
printf 'Resolve conflict markers before committing.\n' >&2; \
exit 1; \
fi; \
size=$$(git cat-file -s ":$$file" 2>/dev/null || printf '0'); \
if [ "$$size" -gt 512000 ] 2>/dev/null; then \
if git show ":$$file" 2>/dev/null | LC_ALL=C grep -Iq .; then \
printf 'Warning: large staged text file (%s bytes): %s\n' "$$size" "$$file" >&2; \
fi; \
fi; \
if [ -n "$$lfs_patterns" ]; then \
for lfs_pat in $$lfs_patterns; do \
case "$$file" in $$lfs_pat) \
content=$$(git show ":$$file" 2>/dev/null | head -1); \
case "$$content" in \
"version https://git-lfs.github.com/spec/v1"*) ;; \
*) \
printf 'LFS-tracked file not using LFS pointer: %s\n' "$$file" >&2; \
printf 'Run "git lfs install" and re-add this file.\n' >&2; \
exit 1; \
;; \
esac; \
break; \
;; \
esac; \
done; \
fi; \
case "$$file" in \
backend/*.go) run_backend_lint=1 ;; \
versionctl/*.go) run_versionctl_lint=1 ;; \
frontend/*.ts|frontend/*.tsx|frontend/*.scss) run_frontend_check=1 ;; \
esac; \
done; \
if [ -n "$$run_backend_lint" ]; then \
printf 'Running backend lint...\n'; \
$(MAKE) _backend-lint; \
fi; \
if [ -n "$$run_versionctl_lint" ]; then \
printf 'Running versionctl lint...\n'; \
$(MAKE) _versionctl-lint; \
fi; \
if [ -n "$$run_frontend_check" ]; then \
printf 'Running frontend check...\n'; \
$(MAKE) _frontend-check; \
fi; \
printf 'Pre-commit checks passed\n'
# ============================================
# 前端
# 版本管理
# ============================================
frontend-install:
cd frontend && bun install
version-sync:
go run ./versionctl sync
frontend-build: frontend-install
cd frontend && bun run build
version-check:
go run ./versionctl check
frontend-dev: frontend-install
cd frontend && bun dev
version-bump: BUMP ?= patch
version-bump: lint test _check-clean-worktree
@set -e; \
bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
new_version=$$(go run ./versionctl bump "$$bump_arg"); \
git add VERSION frontend/; \
git commit -m "chore: 版本升迁 v$$new_version"; \
git tag "v$$new_version"; \
printf '版本升迁完成: v%s\n' "$$new_version"
frontend-test: frontend-install
cd frontend && bun run test
frontend-test-watch: frontend-install
cd frontend && bun run test:watch
frontend-test-coverage: frontend-install
cd frontend && bun run test:coverage
frontend-test-e2e: frontend-install
cd frontend && bun run test:e2e
frontend-lint: frontend-install
cd frontend && bun run lint
frontend-clean:
rm -rf frontend/dist frontend/.next frontend/node_modules frontend/coverage frontend/playwright-report frontend/test-results frontend/tsconfig.tsbuildinfo
_check-clean-worktree:
@if [ -n "$$(git status --porcelain)" ]; then \
printf '工作区不干净,请先提交或清理改动后再执行版本升迁。\n' >&2; \
git status --short; \
exit 1; \
fi
# ============================================
# 桌面应用
# Server 模式
# ============================================
desktop-build: desktop-build-mac desktop-build-win desktop-build-linux
@echo "✅ Desktop builds complete for all platforms"
server-run:
@$(MAKE) -j2 _server-run-backend _server-run-frontend
desktop-prepare-frontend:
@echo "📦 Preparing frontend for desktop..."
server-build: version-check _backend-build _frontend-build
@printf 'Server build complete\n'
server-lint: _backend-lint _frontend-check
@printf 'Server lint complete\n'
server-test: _backend-test _frontend-test
@printf 'Server tests passed\n'
server-clean: _backend-clean _frontend-clean
@printf 'Server artifacts cleaned\n'
_server-run-backend:
@$(MAKE) -C backend run
_server-run-frontend: _frontend-install
cd frontend && bun run dev
# ============================================
# Desktop 模式
# ============================================
desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embedfs
@printf 'Building macOS desktop...\n'
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
lipo -info build/nex-mac-universal | grep -q 'x86_64 arm64'
rm -f build/nex-mac-arm64 build/nex-mac-amd64
@printf 'Packaging macOS app bundle...\n'
rm -rf build/Nex.app
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
@if [ -f assets/icon.icns ]; then \
cp assets/icon.icns build/Nex.app/Contents/Resources/; \
else \
printf 'Missing assets/icon.icns\n'; \
exit 1; \
fi
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
if [ -z "$$MIN_MACOS_VERSION" ]; then \
printf 'Unable to read macOS minimum version\n'; \
exit 1; \
fi; \
go run ./versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
chmod +x build/Nex.app/Contents/MacOS/nex
@printf 'macOS desktop build complete\n'
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource _check-windows-target-arch
@printf 'Building Windows desktop $(TARGET_ARCH)...\n'
mkdir -p build
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../$(WINDOWS_DESKTOP_BINARY) ./cmd/desktop
@printf 'Windows desktop build complete\n'
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _check-linux-target-arch
@printf 'Building Linux desktop $(TARGET_ARCH)...\n'
mkdir -p build
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(LINUX_DESKTOP_BINARY) ./cmd/desktop
@printf 'Linux desktop build complete\n'
desktop-lint: _backend-lint _frontend-check
@printf 'Desktop lint complete\n'
desktop-test: _desktop-test
@printf 'Desktop tests passed\n'
desktop-clean: _desktop-clean
@printf 'Desktop artifacts cleaned\n'
_desktop-test:
cd backend && go test ./cmd/desktop/... -v
_desktop-clean:
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso
_desktop-prepare-frontend: _frontend-install
@printf 'Preparing frontend for desktop...\n'
cd frontend && cp .env.desktop .env.production.local
cd frontend && bun install && bun run build
cd frontend && bun run build
rm -f frontend/.env.production.local
desktop-prepare-embedfs:
@echo "📦 Preparing embedded filesystem..."
_desktop-prepare-embedfs:
@printf 'Preparing embedded filesystem...\n'
rm -rf embedfs/assets embedfs/frontend-dist
cp -r assets embedfs/assets
cp -r frontend/dist embedfs/frontend-dist
desktop-build-mac: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🍎 Building macOS..."
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-mac-arm64 ./cmd/desktop
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-mac-amd64 ./cmd/desktop
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
@echo "📦 Packaging macOS .app..."
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
@if [ -f assets/AppIcon.icns ]; then \
cp assets/AppIcon.icns build/Nex.app/Contents/Resources/; \
else \
echo "⚠️ 未找到 assets/AppIcon.icns"; \
fi
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
if [ -z "$$MIN_MACOS_VERSION" ]; then \
echo "❌ 无法读取 macOS 最低系统版本"; \
exit 1; \
_desktop-prepare-windows-resource: _check-windows-target-arch
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
@WINDRES_CMD="$(WINDRES)"; \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_BFD)"; \
if command -v llvm-windres >/dev/null 2>&1; then \
WINDRES_CMD=llvm-windres; \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
elif "$$WINDRES_CMD" --version 2>&1 | grep -qi LLVM; then \
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
fi; \
{ \
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0">' \
'<dict>' \
' <key>CFBundleDevelopmentRegion</key>' \
' <string>zh-Hans</string>' \
' <key>CFBundleExecutable</key>' \
' <string>nex</string>' \
' <key>CFBundleIconFile</key>' \
' <string>AppIcon</string>' \
' <key>CFBundleIdentifier</key>' \
' <string>com.lanyuanxiaoyao.nex</string>' \
' <key>CFBundleInfoDictionaryVersion</key>' \
' <string>6.0</string>' \
' <key>LSApplicationCategoryType</key>' \
' <string>public.app-category.developer-tools</string>' \
' <key>CFBundleName</key>' \
' <string>Nex</string>' \
' <key>CFBundleDisplayName</key>' \
' <string>Nex</string>' \
' <key>CFBundlePackageType</key>' \
' <string>APPL</string>' \
' <key>CFBundleShortVersionString</key>' \
' <string>1.0.0</string>' \
' <key>CFBundleVersion</key>' \
' <string>1.0.0</string>' \
' <key>NSHumanReadableCopyright</key>' \
' <string>Copyright © 2026 Nex</string>' \
' <key>LSMinimumSystemVersion</key>' \
" <string>$$MIN_MACOS_VERSION</string>" \
' <key>LSUIElement</key>' \
' <true/>' \
' <key>NSHighResolutionCapable</key>' \
' <true/>' \
'</dict>' \
'</plist>'; \
} > build/Nex.app/Contents/Info.plist
chmod +x build/Nex.app/Contents/MacOS/nex
@echo "✅ macOS app packaged: build/Nex.app"
desktop-build-win: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🪟 Building Windows..."
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-win-amd64.exe ./cmd/desktop
desktop-build-linux: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🐧 Building Linux..."
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o ../build/nex-linux-amd64 ./cmd/desktop
desktop-dev: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🖥️ Starting desktop app in dev mode..."
cd backend && go run ./cmd/desktop
desktop-test:
cd backend && go test ./cmd/desktop/... -v
desktop-clean:
rm -rf build/ embedfs/assets embedfs/frontend-dist
command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \
cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F "$$WINDRES_FMT" -i icon_windows.rc -o $(WINDOWS_RESOURCE)
# ============================================
# 清理
# 发布资产
# ============================================
clean: backend-clean frontend-clean desktop-clean
@echo "✅ Clean complete"
release-assets-check:
go run ./versionctl release-assets-check
@printf 'Release assets check passed\n'
release-assets-web: version-check release-assets-check _frontend-build
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name web tar.gz); \
tar -C frontend -czf "$(RELEASE_DIR)/$$asset" dist
release-assets-linux: version-check release-assets-check _check-linux-target-arch
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
release-assets-windows: version-check release-assets-check _check-windows-target-arch
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
release-assets-macos: version-check release-assets-check
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-macos RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-macos RELEASE_DIR="$(RELEASE_DIR)"
release-assets-server-linux: version-check _check-linux-target-arch
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-$(TARGET_ARCH) ./cmd/server
asset=$$(go run ./versionctl asset-name server linux $(TARGET_ARCH) tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-linux-$(TARGET_ARCH)
release-assets-server-windows: version-check _check-windows-target-arch
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(WINDOWS_SERVER_BINARY) ./cmd/server
asset=$$(go run ./versionctl asset-name server windows $(TARGET_ARCH) zip); \
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_SERVER_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
release-assets-server-macos: version-check
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-amd64 ./cmd/server
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-arm64 ./cmd/server
lipo -create build/nex-server-macos-amd64 build/nex-server-macos-arm64 -output build/nex-server-macos-universal
lipo -info build/nex-server-macos-universal | grep -q 'x86_64 arm64'
asset=$$(go run ./versionctl asset-name server macos amd64 tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-amd64
asset=$$(go run ./versionctl asset-name server macos arm64 tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal
rm -f build/nex-server-macos-amd64 build/nex-server-macos-arm64 build/nex-server-macos-universal
release-assets-desktop-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
release-assets-desktop-windows: version-check release-assets-check desktop-build-win
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop windows $(TARGET_ARCH) zip); \
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
release-assets-desktop-macos: version-check release-assets-check desktop-build-mac _package-macos-zip _package-macos-dmg
rm -rf build/Nex.app build/dmg
release-assets-checksums:
@cd "$(RELEASE_DIR)" && \
rm -f SHA256SUMS && \
for asset in *; do \
[ -f "$$asset" ] || continue; \
if command -v sha256sum >/dev/null 2>&1; then \
sha256sum "$$asset"; \
elif command -v shasum >/dev/null 2>&1; then \
shasum -a 256 "$$asset"; \
else \
printf 'Missing sha256sum or shasum\n' >&2; \
exit 1; \
fi; \
done > SHA256SUMS && \
test -s SHA256SUMS
_check-linux-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
printf 'Unsupported Linux TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \
fi
_check-windows-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ]; then \
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \
fi
_ensure-appimagetool:
@mkdir -p build/tools
@if [ ! -x "$(APPIMAGETOOL)" ]; then \
printf 'Downloading appimagetool for %s...\n' "$(APPIMAGE_ARCH)"; \
command -v curl >/dev/null 2>&1 || { printf 'Missing curl for appimagetool download\n'; exit 1; }; \
curl -L "$(APPIMAGETOOL_URL)" -o "$(APPIMAGETOOL)"; \
chmod +x "$(APPIMAGETOOL)"; \
fi; \
printf 'Using appimagetool: %s\n' "$(APPIMAGETOOL)"
_package-linux-tar:
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-linux-$(TARGET_ARCH)
_package-linux-appimage: _ensure-appimagetool
mkdir -p "$(RELEASE_DIR)"
appdir="build/appimage/nex-$(TARGET_ARCH).AppDir"; \
rm -rf "$$appdir"; \
mkdir -p "$$appdir/usr/bin" "$$appdir/usr/share/applications" "$$appdir/usr/share/icons"; \
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$appdir/usr/bin/nex"; \
install -m 0644 packaging/linux/nex.desktop "$$appdir/nex.desktop"; \
install -m 0644 packaging/linux/nex.desktop "$$appdir/usr/share/applications/nex.desktop"; \
install -m 0755 packaging/linux/AppRun "$$appdir/AppRun"; \
cp -R assets/icons/hicolor "$$appdir/usr/share/icons/"; \
cp assets/icon.png "$$appdir/nex.png"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) AppImage); \
ARCH=$(APPIMAGE_ARCH) APPIMAGE_EXTRACT_AND_RUN=1 "$(APPIMAGETOOL)" "$$appdir" "$(RELEASE_DIR)/$$asset"; \
chmod +x "$(RELEASE_DIR)/$$asset"; \
test -s "$(RELEASE_DIR)/$$asset"
_package-linux-deb:
mkdir -p "$(RELEASE_DIR)"
pkgdir="build/pkg/deb/nex-$(TARGET_ARCH)"; \
rm -rf "$$pkgdir"; \
mkdir -p "$$pkgdir/DEBIAN" "$$pkgdir/usr/bin" "$$pkgdir/usr/share/applications" "$$pkgdir/usr/share/icons"; \
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$pkgdir/usr/bin/nex"; \
install -m 0644 packaging/linux/nex.desktop "$$pkgdir/usr/share/applications/nex.desktop"; \
cp -R assets/icons/hicolor "$$pkgdir/usr/share/icons/"; \
printf '%s\n' \
'Package: nex' \
'Version: $(VERSION)' \
'Section: utils' \
'Priority: optional' \
'Architecture: $(DEB_ARCH)' \
'Maintainer: Nex Maintainers <noreply@example.com>' \
'Depends: libgtk-3-0, libayatana-appindicator3-1, xdg-utils' \
'Description: AI Gateway desktop application' \
' Nex is an AI Gateway desktop application.' \
> "$$pkgdir/DEBIAN/control"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) deb); \
dpkg-deb --build --root-owner-group "$$pkgdir" "$(RELEASE_DIR)/$$asset"; \
dpkg-deb -I "$(RELEASE_DIR)/$$asset" >/dev/null
_package-linux-rpm:
mkdir -p "$(RELEASE_DIR)"
topdir="$(abspath build/rpmbuild-$(TARGET_ARCH))"; \
rm -rf "$$topdir"; \
mkdir -p "$$topdir/BUILD" "$$topdir/BUILDROOT" "$$topdir/RPMS" "$$topdir/SOURCES" "$$topdir/SPECS" "$$topdir/SRPMS"; \
rpmbuild -bb --target "$(RPM_ARCH)" \
--define "_topdir $$topdir" \
--define "nex_version $(VERSION)" \
--define "nex_binary $(abspath $(LINUX_DESKTOP_BINARY))" \
--define "nex_desktop_file $(abspath packaging/linux/nex.desktop)" \
--define "nex_icons_dir $(abspath assets/icons/hicolor)" \
packaging/linux/nex.spec; \
rpm_file=$$(find "$$topdir/RPMS" -type f -name '*.rpm' | sort | tail -n 1); \
test -n "$$rpm_file"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) rpm); \
cp "$$rpm_file" "$(RELEASE_DIR)/$$asset"; \
rpm -qip "$(RELEASE_DIR)/$$asset" >/dev/null
_package-macos-zip:
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop macos universal zip); \
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$$asset"
_package-macos-dmg:
mkdir -p "$(RELEASE_DIR)"
dmgdir="build/dmg/Nex"; \
rm -rf "$$dmgdir"; \
mkdir -p "$$dmgdir"; \
cp -R build/Nex.app "$$dmgdir/Nex.app"; \
ln -s /Applications "$$dmgdir/Applications"; \
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
rm -rf "$$dmgdir"
# ============================================
# 共享 helper targets
# ============================================
_backend-build:
@$(MAKE) -C backend build
_backend-lint:
@$(MAKE) -C backend lint
_backend-test:
@$(MAKE) -C backend test
_backend-clean:
@$(MAKE) -C backend clean
_versionctl-lint:
@$(MAKE) -C versionctl lint
_versionctl-test:
@$(MAKE) -C versionctl test
_frontend-install:
cd frontend && bun install
_frontend-build: _frontend-install
cd frontend && bun run build
_frontend-check: _frontend-install
cd frontend && bun run check
_frontend-test: _frontend-install
cd frontend && bun run test
_frontend-dev: _frontend-install
cd frontend && bun run dev
_frontend-clean:
rm -rf frontend/dist frontend/.next frontend/coverage frontend/playwright-report frontend/test-results frontend/tsconfig.tsbuildinfo

264
README.md
View File

@@ -27,7 +27,7 @@ nex/
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
│ │ ├── hooks/ # TanStack Query hooks
│ │ ├── components/ # 通用组件AppLayout
│ │ ├── pages/ # 页面Providers, Stats
│ │ ├── pages/ # 页面Providers, Stats, Settings
│ │ ├── routes/ # React Router 路由配置
│ │ ├── types/ # TypeScript 类型定义
│ │ └── __tests__/ # 单元测试 + 组件测试
@@ -36,9 +36,11 @@ nex/
├── assets/ # 应用资源
│ ├── icon.png # 托盘图标
│ ├── AppIcon.icns # macOS 应用图标
│ ├── icon.icns # macOS 应用图标
│ └── icon.ico # Windows 应用图标
├── packaging/ # 桌面发布包元数据Linux desktop entry、RPM spec 等)
└── README.md # 本文件
```
@@ -47,7 +49,7 @@ nex/
- **双协议支持**:同时支持 OpenAI 和 Anthropic 协议
- **跨协议转换**Hub-and-Spoke 架构实现 OpenAI ↔ Anthropic 双向转换
- **统一模型 ID**`provider_id/model_name` 格式全局唯一标识模型(如 `openai/gpt-4`
- **Smart Passthrough**:同协议请求零序列化开销,仅改写 model 字段
- **Smart Passthrough**:同协议请求跳过 Canonical 全量转换,仅在 JSON 层改写 model 字段
- **流式响应**:完整支持 SSE 流式传输,包括跨协议流式转换
- **Function Calling**支持工具调用Tools
- **Thinking / Reasoning**:支持 OpenAI `reasoning_effort` 和 Anthropic `thinking` 配置
@@ -55,6 +57,7 @@ nex/
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
- **用量统计**:按供应商、模型、日期统计请求数量
- **Web 配置界面**:提供供应商和模型配置管理
- **启动参数设置**:通过 Web 界面查看和编辑启动参数Desktop 可编辑、Server 只读)
## 技术栈
@@ -64,7 +67,7 @@ nex/
- **ORM**: GORM
- **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识)
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值
- **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10
- **迁移**: goose
@@ -91,7 +94,7 @@ JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method":
- **图表库**: Recharts
- **路由**: React Router v7
- **数据获取**: TanStack Query v5
- **样式**: SCSS Modules
- **样式**: TDesign 组件 props 优先TDesign tokens 次之SCSS 作为兜底补充
- **测试**: Vitest + React Testing Library + Playwright
## 快速开始
@@ -110,12 +113,12 @@ make desktop-build-win
# Linux
make desktop-build-linux
# 构建所有平台
make desktop-build
# Linux arm64
make desktop-build-linux TARGET_ARCH=arm64
```
**使用桌面应用**
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64 / nex-linux-arm64
- 系统托盘图标出现,浏览器自动打开管理界面
- 点击托盘图标显示菜单,可打开管理界面或退出
- 关闭浏览器后服务继续运行,可通过托盘重新打开
@@ -123,8 +126,10 @@ make desktop-build
**注意事项**
- 桌面应用需要 CGO 支持
- macOS: 自带 Xcode Command Line Tools
- Linux: 自带 gcc部分桌面环境需要 `libappindicator3-dev`
- Windows: 需要 MinGW-w64 或在 Windows 环境构建
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包Ubuntu/Debian: `libgtk-3-dev``libayatana-appindicator3-dev`
- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utils启动失败提示会 best-effort 使用 `notify-send``kdialog``zenity``xmessage`,这些通知/弹窗工具为软依赖缺失时会降级到标准错误输出或日志AppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链desktop 使用 GUI linker flags 隐藏控制台窗口
- macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示
**Linux 桌面环境兼容性**
- GNOME: 需要 AppIndicator 扩展
@@ -132,50 +137,87 @@ make desktop-build
- Xfce: 需要 libappindicator
- 其他支持 StatusNotifierItem 规范的环境
### CLI 模式
#### 后端
### Server 模式(前后端分离)
```bash
cd backend
go mod download
go run cmd/server/main.go
make server-run
```
后端服务将在 `http://localhost:9826` 启动。首次启动会自动:
- 创建配置文件 `~/.nex/config.yaml`
`make server-run` 会并行启动:
- 后端服务:`http://localhost:9826`
- 前端开发服务器:`http://localhost:5173`
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
- 初始化数据库 `~/.nex/config.db`
- 运行数据库迁移
- 创建日志目录 `~/.nex/log/`
### 前端
**构建 server 模式产物**
```bash
cd frontend
bun install
bun dev
make server-build
```
前端开发服务器将在 `http://localhost:5173` 启动API 请求通过 Vite proxy 转发到后端。
### Release 产物
发布流程由 Git tag `vX.Y.Z` 触发GitHub Actions 会先通过全流程测试门禁,再构建并创建 Draft Release上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`
**server 产物**(不内置 Web 管理界面):
| 平台 | 产物 |
|------|------|
| Linux amd64 | `nex-server_<version>_linux_amd64.tar.gz` |
| Linux arm64 | `nex-server_<version>_linux_arm64.tar.gz` |
| macOS amd64 | `nex-server_<version>_macos_amd64.tar.gz` |
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
**web 产物**
| 内容 | 产物 |
|------|------|
| `frontend/dist` | `nex-web_<version>.tar.gz` |
**desktop 产物**
| 平台 | 产物 |
|------|------|
| Linux amd64 | `nex-desktop_<version>_linux_amd64.tar.gz``.AppImage``.deb``.rpm` |
| Linux arm64 | `nex-desktop_<version>_linux_arm64.tar.gz``.AppImage``.deb``.rpm` |
| macOS universal | `nex-desktop_<version>_macos_universal.zip``nex-desktop_<version>_macos_universal.dmg` |
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
Linux deb 包声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行依赖rpm 包声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 `go:embed` 嵌入二进制),安装后首次启动不再依赖仓库源码目录。
## API 接口
### 代理接口(对外部应用)
代理接口统一使用 `/{protocol}/*path` 路由格式,模型 ID 使用 `provider_id/model_name` 格式(如 `openai/gpt-4`)。同协议请求走 Smart Passthrough最小化 JSON 改写保持参数保真;跨协议请求走完整 decode/encode 转换。
代理接口统一使用 `/{protocol}/*path` 路由格式,模型 ID 使用 `provider_id/model_name` 格式(如 `openai/gpt-4`)。同协议请求走 Smart Passthrough最小化 JSON 改写保持未改写字段的 JSON 内容和类型不变;跨协议请求走完整 decode/encode 转换。
**OpenAI 协议**`protocol=openai`
- `POST /openai/chat/completions` - 对话补全
- `GET /openai/models` - 模型列表(本地数据库聚合)
- `GET /openai/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
- `POST /openai/embeddings` - 嵌入
- `POST /openai/rerank` - 重排序
- `POST /openai/v1/chat/completions` - 对话补全
- `GET /openai/v1/models` - 模型列表(本地数据库聚合)
- `GET /openai/v1/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
- `POST /openai/v1/embeddings` - 嵌入
- `POST /openai/v1/rerank` - 重排序
**Anthropic 协议**`protocol=anthropic`
- `POST /anthropic/v1/messages` - 消息对话
- `GET /anthropic/v1/models` - 模型列表(本地数据库聚合)
- `GET /anthropic/v1/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
路径边界:网关只剥离第一段协议前缀,剩余路径保持协议原生形态交给 adapter。OpenAI adapter 接收 `/v1/chat/completions``/v1/models``/v1/embeddings``/v1/rerank`,并在构建上游 URL 时去掉 `/v1`Anthropic adapter 接收 `/v1/messages``/v1/models`。因此 OpenAI 供应商 `base_url` 配置到版本路径一级(如 `https://api.openai.com/v1`Anthropic 供应商 `base_url` 配置到域名级(如 `https://api.anthropic.com`)。
代理错误边界:网关层错误统一返回 `{"error":"...","code":"..."}`,例如 `INVALID_JSON``MODEL_NOT_FOUND``CONVERSION_FAILED``UPSTREAM_UNAVAILABLE`。只要上游已经返回 HTTP 响应,非 2xx 的 status、过滤 hop-by-hop header 后的 headers 和 body 会直接透传,不包装为应用错误或协议错误。
模型路由边界:只有 adapter 明确适配的接口会解析请求体中的 `model` 并使用统一模型 ID 路由;未知接口即使包含顶层 `model` 也按无 model 透传处理。
流式边界:同协议无响应 model 改写时原样透传 SSE frame 和 `[DONE]`;同协议需要响应 model 改写时只解析 SSE frame 的 `data` JSON 并改写 `model`;跨协议流式仍走 provider decoder → Canonical stream event → client encoder。
### 管理接口(对前端)
#### 供应商管理
@@ -198,13 +240,29 @@ bun dev
查询参数支持:`provider_id``model_name``start_date``end_date``group_by`
#### 启动参数设置
- `GET /api/settings/startup` - 查询启动参数设置
- `PUT /api/settings/startup` - 保存启动参数设置(仅 Desktop 模式)
**行为差异**
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403
响应包含 `mode``editable``config_path``restart_required` 元数据和完整启动参数配置。Duration 字段使用 Go `time.Duration.String()` 标准字符串格式(如 `30s``1m0s``1h0m0s`);配置文件中用户可手写任意合法 Go duration 字符串(如 `1h``30m`),保存时系统会统一为标准格式。
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
## 配置
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
配置方式取决于启动模式:
- **Server 模式**`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
- **Desktop 模式**`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效
### 配置文件
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件
```yaml
server:
@@ -223,7 +281,7 @@ database:
# dbname: nex
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 1h
conn_max_lifetime: 1h0m0s
log:
level: info
@@ -234,9 +292,9 @@ log:
compress: true
```
### 环境变量
### 环境变量(仅 Server 模式)
所有配置项支持环境变量,使用 `NEX_` 前缀:
Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
```bash
export NEX_SERVER_PORT=9000
@@ -254,7 +312,11 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。
### CLI 参数
**Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
### CLI 参数(仅 Server 模式)
Server 模式下,支持命令行参数:
```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
@@ -262,6 +324,8 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转 kebab-case`server.port``--server-port`)。
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
### 数据文件
- `~/.nex/config.yaml` - 配置文件
@@ -271,53 +335,113 @@ export NEX_DATABASE_DBNAME=nex
## 测试
```bash
# 顶层便捷命令
make test # 运行所有测试
# 全局默认测试(不含 MySQL 和前端 E2E
make test
# 后端测试
make backend-test # 后端测试
make backend-test-coverage # 后端覆盖率
make backend-test-unit # 后端单元测试
make backend-test-integration # 后端集成测试
# 前端测试
make frontend-test # 前端测试
make frontend-test-e2e # 前端 E2E 测试
make frontend-test-coverage # 前端覆盖率
# 产品级测试
make server-test
make desktop-test
```
backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `backend/README.md``frontend/README.md`
## 开发
```bash
# 首次克隆后安装 Git hooks
lefthook install
make hooks-install
# 顶层便捷命令
make dev # 启动开发环境(并行启动后端和前端)
make build # 构建所有产物
make lint # 检查所有代码
make clean # 清理所有构建产物
# 检查 Git hooks 安装状态
make hooks-check
# 后端开发
make backend-build # 构建后端
make backend-run # 运行后端
make backend-dev # 后端开发模式
make backend-lint # 后端代码检查
make backend-clean # 清理后端构建产物
# 运行 Git hooks 回归测试
make hooks-test
# 数据库操作
make backend-db-up # 数据库迁移
make backend-db-down # 数据库回滚
make backend-db-status # 数据库迁移状态
make backend-db-create # 创建新迁移
# 全局命令
make lint # 前后端共享检查
make test # 默认全量测试(不含 MySQL/E2E
make clean # 清理所有构建产物和测试报告
# 前端开发
make frontend-build # 构建前端
make frontend-dev # 前端开发模式
make frontend-lint # 前端代码检查
make frontend-clean # 清理前端构建产物
# server 模式
make server-run # 并行启动后端和前端开发服务
make server-build # 构建 backend/bin/server 和 frontend/dist
make server-lint # server 模式检查
make server-test # server 模式测试
make server-clean # 清理 server 模式产物
# desktop 模式
make desktop-build-mac # 构建 macOS 桌面应用
make desktop-build-win # 构建 Windows 桌面应用
make desktop-build-linux # 构建 Linux 桌面应用
make desktop-lint # desktop 模式检查
make desktop-test # desktop 专属测试
make desktop-clean # 清理 desktop 产物
```
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
- pre-commit检查 staged files 的冲突标记、大文件告警和 LFS 指针并按文件类型委托后端、versionctl、前端检查
- prepare-commit-msg在编辑器打开时提供提交信息模板辅助填写 `类型: 简短描述` 和多行说明
- commit-msg校验提交信息格式为 `类型: 简短描述`多行说明需在首行后保留空行提交描述按项目规范使用中文hook 不做字符集检测
## 版本与发布
### 统一版本源
- 仓库根目录 `VERSION` 是全仓唯一版本源,格式固定为 `x.y.z`
- `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION` 由仓库工具同步,不能手工漂移
### 本地版本演进
```bash
# 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag
make version-bump BUMP=minor
# 或指定具体版本号
make version-bump SET_VERSION=1.0.0
# 推送到远程
git push --follow-tags
```
手动同步和校验:
```bash
make version-sync
make version-check
```
### 本地生成发布资产
```bash
# Linux: server + desktop
make release-assets-linux
# Windows: server + desktop需在 Windows 环境执行)
make release-assets-windows
# macOS: darwin-amd64 server、darwin-arm64 server、desktop universal
make release-assets-macos
```
生成的版本化发布资产位于 `build/release/`
### GitHub Draft Release
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
- 流水线会先校验 tag 与 `VERSION` 一致再执行全流程测试门禁lint、默认测试、MySQL 测试、E2E 测试),测试不通过则阻止构建
- 测试通过后,三个平台 job 并行构建,各 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
- 构建以下资产并上传到 GitHub Draft Release
- Linux server
- Windows server
- darwin-amd64 server
- darwin-arm64 server
- Linux desktop
- Windows desktop
- macOS desktop universal
- Release 默认以 Draft 形式创建,需人工检查后再公开发布
## 开发规范
详见各子项目的 README.md
@@ -326,4 +450,4 @@ make frontend-clean # 清理前端构建产物
## 许可证
MIT
Apache License 2.0

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.7

Binary file not shown.

View File

@@ -1,64 +0,0 @@
# Assets
应用资源文件目录。
## 文件说明
| 文件 | 用途 | 尺寸 | 格式 |
|------|------|------|------|
| `icon.svg` | 源图标 | 64x64 | SVG |
| `icon.png` | 托盘图标 | 64x64 | PNG |
| `AppIcon.icns` | macOS 应用图标 | 多尺寸 | ICNS |
| `icon.ico` | Windows 应用图标 | 256x256 | ICO |
## 替换图标
### 1. 准备图标
推荐使用 SVG 格式的源图标,尺寸至少 256x256。
### 2. 生成各平台图标
**托盘图标 (PNG)**
```bash
magick your-icon.svg -resize 64x64 icon.png
```
**macOS 应用图标 (ICNS)**
```bash
mkdir icon.iconset
magick your-icon.svg -resize 16x16 icon.iconset/icon_16x16.png
magick your-icon.svg -resize 32x32 icon.iconset/icon_16x16@2x.png
magick your-icon.svg -resize 32x32 icon.iconset/icon_32x32.png
magick your-icon.svg -resize 64x64 icon.iconset/icon_32x32@2x.png
magick your-icon.svg -resize 128x128 icon.iconset/icon_128x128.png
magick your-icon.svg -resize 256x256 icon.iconset/icon_128x128@2x.png
iconutil -c icns icon.iconset -o AppIcon.icns
rm -rf icon.iconset
```
**Windows 应用图标 (ICO)**
```bash
magick your-icon.svg -resize 256x256 icon.ico
```
### 3. 替换文件
将生成的文件放入此目录,然后重新构建桌面应用:
```bash
./scripts/build/build-darwin-arm64.sh
```
## macOS Template 图标
macOS 支持 Template 图标,自动适配深浅色模式:
- 使用黑色 + 透明设计
- 文件名以 `Template` 结尾(如 `iconTemplate.png`
- 黑色在深色模式下自动变为白色
## 设计建议
- 托盘图标应简洁,在小尺寸下清晰可辨
- 避免过多细节和文字
- 使用高对比度颜色
- macOS 建议使用 Template 图标风格

BIN
assets/icon.icns LFS Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 131 B

View File

@@ -1,13 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#4A90D9"/>
<polygon points="32,8 52,20 52,44 32,56 12,44 12,20" fill="none" stroke="white" stroke-width="3"/>
<circle cx="32" cy="32" r="6" fill="white"/>
<line x1="32" y1="32" x2="20" y2="20" stroke="white" stroke-width="2"/>
<line x1="32" y1="32" x2="44" y2="20" stroke="white" stroke-width="2"/>
<line x1="32" y1="32" x2="20" y2="44" stroke="white" stroke-width="2"/>
<line x1="32" y1="32" x2="44" y2="44" stroke="white" stroke-width="2"/>
<circle cx="20" cy="20" r="3" fill="white"/>
<circle cx="44" cy="20" r="3" fill="white"/>
<circle cx="20" cy="44" r="3" fill="white"/>
<circle cx="44" cy="44" r="3" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

97
backend/Makefile Normal file
View File

@@ -0,0 +1,97 @@
.PHONY: \
build run \
test test-unit test-integration test-coverage \
lint clean \
migrate-up migrate-down migrate-status migrate-create \
mysql-up mysql-down mysql-test mysql-test-quick
VERSION := $(shell go run ../versionctl print)
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
DB_DRIVER ?= sqlite3
DB_DSN ?= $(HOME)/.nex/config.db
ifeq ($(DB_DRIVER),mysql)
GOOSE_DIR := migrations/mysql
GOOSE_DRIVER := mysql
else ifeq ($(DB_DRIVER),sqlite3)
GOOSE_DIR := migrations/sqlite
GOOSE_DRIVER := sqlite3
else
$(error unsupported DB_DRIVER '$(DB_DRIVER)', use sqlite3 or mysql)
endif
build:
go build -ldflags "$(GO_LDFLAGS)" -o bin/server ./cmd/server
run:
go run -ldflags "$(GO_LDFLAGS)" ./cmd/server
test:
go test ./internal/... ./pkg/... ./tests/... ./cmd/server/... -v
test-unit:
go test ./internal/... ./pkg/... -v
test-integration:
go test ./tests/... -v
test-coverage:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
@printf 'Coverage report generated: backend/coverage.html\n'
lint:
go tool golangci-lint run ./...
clean:
rm -rf bin/ coverage.out coverage.html
migrate-up:
@printf 'Running database migration up...\n'
goose -dir $(GOOSE_DIR) $(GOOSE_DRIVER) "$(DB_DSN)" up
migrate-down:
@printf 'Running database migration down...\n'
goose -dir $(GOOSE_DIR) $(GOOSE_DRIVER) "$(DB_DSN)" down
migrate-status:
@printf 'Checking database migration status...\n'
goose -dir $(GOOSE_DIR) $(GOOSE_DRIVER) "$(DB_DSN)" status
migrate-create:
@printf 'Migration name: '; \
read name; \
goose -dir migrations/sqlite create $$name sql; \
goose -dir migrations/mysql create $$name sql
mysql-up:
@printf 'Starting MySQL test container...\n'
cd tests/mysql && docker-compose up -d
@printf 'Waiting for MySQL to be ready...\n'
@for i in $$(seq 1 30); do \
if docker exec nex-mysql-test mysqladmin ping -h localhost -u root -ptestpass --silent 2>/dev/null; then \
printf 'MySQL is ready\n'; \
exit 0; \
fi; \
printf 'Waiting... (%s/30)\n' $$i; \
sleep 1; \
done; \
printf 'MySQL failed to start\n'; \
exit 1
mysql-down:
@printf 'Stopping MySQL test container...\n'
cd tests/mysql && docker-compose down -v
mysql-test:
@set -e; \
$(MAKE) mysql-up; \
trap '$(MAKE) mysql-down' EXIT; \
go test -tags=mysql ./tests/mysql/... -v -count=1
mysql-test-quick:
@printf 'Running MySQL tests without container management...\n'
go test -tags=mysql ./tests/mysql/... -v -count=1

View File

@@ -4,10 +4,10 @@ AI 网关后端服务,提供统一的大模型 API 代理接口。
## 功能特性
- 支持 OpenAI 协议(`/openai/v1/...`
- 支持 OpenAI 协议(`/openai/v1/...`,例如 `/openai/v1/chat/completions`
- 支持 Anthropic 协议(`/anthropic/v1/...`
- 支持 Hub-and-Spoke 跨协议双向转换OpenAI ↔ Anthropic
- 同协议透传(零语义损失、零序列化开销
- 同协议透传(跳过 Canonical 全量转换,保持协议语义
- 支持流式响应SSE
- 支持 Function Calling / Tools
- 支持 Thinking / Reasoning
@@ -54,7 +54,7 @@ func NewProxyHandler(..., logger *zap.Logger) *ProxyHandler {
使用 `pkg/logger/field.go` 中定义的字段构造函数:
```go
logger.Info("请求开始",
logger.Debug("请求开始",
pkglogger.Method("POST"),
pkglogger.Path("/v1/chat"),
pkglogger.RequestID("xxx"),
@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zapSQL 查询映射到 Debug 级别。
- **ORM**: GORM
- **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值
- **配置**: Viper + pflagServer 多层配置Desktop 配置文件快照
- **验证**: go-playground/validator/v10
- **迁移**: goose
@@ -164,7 +164,11 @@ backend/
│ └── validator/ # 验证器
│ └── validator.go
├── migrations/ # 数据库迁移
── 20260421000001_initial_schema.sql
── embed.go # go:embed 迁移资源入口
│ ├── sqlite/
│ │ └── 20260421000001_initial_schema.sql
│ └── mysql/
│ └── 20260421000001_initial_schema.sql
├── tests/ # 集成测试
│ ├── helpers.go # 测试辅助函数
│ ├── config/ # 测试配置
@@ -220,7 +224,7 @@ OpenAI Response ← Canonical Response ← Anthropic Response
### Smart Passthrough 机制
同协议请求走 Smart Passthrough 路径,**零序列化开销**
同协议请求走 Smart Passthrough 路径,不进入 Canonical 全量转换
```
1. 检测 clientProtocol == providerProtocol
@@ -229,12 +233,14 @@ OpenAI Response ← Canonical Response ← Anthropic Response
4. 响应中仅改写 model 字段upstream_model_name → unified_id
```
Smart Passthrough 保持未改写 JSON 字段的内容和类型不变,不承诺保留原始字节顺序、空白或对象字段顺序。
### 流式转换器层次
```
StreamConverter (接口)
├── PassthroughStreamConverter # 直接透传,无任何处理
├── SmartPassthroughStreamConverter # 同协议 + 逐 chunk 改写 model
├── SmartPassthroughStreamConverter # 同协议 + 按 SSE frame 改写 data JSON model
└── CanonicalStreamConverter # 跨协议完整转换decode → encode
```
@@ -301,6 +307,7 @@ StreamConverter (接口)
| `PROTOCOL_CONSTRAINT_VIOLATION` | 协议约束违反 |
| `ENCODING_FAILURE` | 编码失败 |
| `INTERFACE_NOT_SUPPORTED` | 接口不支持(如 Anthropic Embeddings |
| `UNSUPPORTED_MULTIMODAL` | 跨协议暂不支持多模态内容 |
### AppError 预定义错误
@@ -327,15 +334,18 @@ go mod download
go run cmd/server/main.go
```
服务将在端口 9826 启动。首次启动会自动创建配置文件和运行数据库迁移。
服务将在端口 9826 启动。首次启动会自动运行数据库迁移。
## 配置
配置支持多种方式:配置文件、环境变量、命令行参数,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
配置方式取决于启动入口:
- **Server 入口**`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
- **Desktop 入口**`cmd/desktop`):仅支持 `~/.nex/config.yaml` > 默认值,不支持 CLI 参数和 `NEX_*` 环境变量覆盖,修改配置文件后需重启生效
### 配置文件
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成。
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
```yaml
server:
@@ -354,7 +364,7 @@ database:
# dbname: nex
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 1h
conn_max_lifetime: 1h0m0s
log:
level: info
@@ -365,9 +375,9 @@ log:
compress: true
```
### 环境变量
### 环境变量(仅 Server 入口)
所有配置项都支持环境变量,使用 `NEX_` 前缀:
Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
```bash
export NEX_SERVER_PORT=9000
@@ -385,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。
### 命令行参数
### 命令行参数(仅 Server 入口)
```bash
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
@@ -434,24 +444,39 @@ docker run -d -e NEX_SERVER_PORT=9000 -e NEX_LOG_LEVEL=info nex-server
## 测试
```bash
# 运行所有测试
# 运行 backend 默认测试
make test
# 分类测试
make test-unit
make test-integration
# 生成覆盖率报告
make test-coverage
# MySQL 专项测试
make mysql-up
make mysql-down
make mysql-test
make mysql-test-quick
```
## 数据库迁移
应用启动时使用随二进制打包的迁移资源(`go:embed`自动执行迁移server 和 desktop 发布产物均自包含,不依赖源码目录。开发时可继续通过 Makefile goose CLI 操作文件系统中的 `migrations/<dialect>/` 目录,运行时嵌入资源与文件系统目录共享同一批 SQL 文件。
```bash
# 使用 Makefile
make migrate-up DB_PATH=~/.nex/config.db
make migrate-down DB_PATH=~/.nex/config.db
make migrate-status DB_PATH=~/.nex/config.db
make migrate-up DB_DSN=~/.nex/config.db
make migrate-down DB_DSN=~/.nex/config.db
make migrate-status DB_DSN=~/.nex/config.db
# 创建新迁移
make migrate-create
# MySQL 迁移
make migrate-up DB_DRIVER=mysql DB_DSN='user:pass@tcp(localhost:3306)/nex'
# 或直接使用 goose
goose -dir migrations sqlite3 ~/.nex/config.db up
```
@@ -460,15 +485,15 @@ goose -dir migrations sqlite3 ~/.nex/config.db up
### 代理接口
使用 `/{protocol}/v1/{path}` URL 前缀路由
使用 `/{protocol}/*path` URL 前缀路由。网关只剥离第一段协议前缀,不在 Handler 中统一添加或移除 `/v1`;剩余 path 是协议原生 nativePath由对应 adapter 识别和组合上游 URL。
#### OpenAI 协议
```
POST /openai/chat/completions
GET /openai/models
POST /openai/embeddings
POST /openai/rerank
POST /openai/v1/chat/completions
GET /openai/v1/models
POST /openai/v1/embeddings
POST /openai/v1/rerank
```
#### Anthropic 协议
@@ -478,10 +503,20 @@ POST /anthropic/v1/messages
GET /anthropic/v1/models
```
**协议转换**:网关支持任意协议间的双向转换。客户端使用 OpenAI 协议请求,上游供应商可以是 Anthropic 协议(反之亦然)。同协议时自动透传,零序列化开销
**协议转换**:网关支持任意协议间的双向转换。客户端使用 OpenAI 协议请求,上游供应商可以是 Anthropic 协议(反之亦然)。同协议时自动透传或 Smart Passthrough跳过 Canonical 全量转换
**统一模型 ID**:代理请求中的 `model` 字段使用 `provider_id/model_name` 格式(如 `openai/gpt-4`),网关据此路由到对应供应商。同协议时自动改写为上游 `model_name`,跨协议时通过全量转换处理。
**base_url 约定**
- OpenAI 供应商配置到版本路径一级,例如 `https://api.openai.com/v1`;当客户端请求 `/openai/v1/chat/completions`OpenAI adapter 会把 nativePath `/v1/chat/completions` 映射为上游 path `/chat/completions`,最终 URL 为 `https://api.openai.com/v1/chat/completions`
- Anthropic 供应商配置到域名级,例如 `https://api.anthropic.com`
**模型提取边界**:只有 adapter 明确适配的 Chat、Embeddings、Rerank 等接口会提取 `model` 并尝试统一模型 ID 路由。未知接口不做顶层 `model` 猜测,直接按无 model 透传。
**流式透传边界**:同协议无响应 model 改写时 raw passthrough保留 SSE frame 边界和 `[DONE]`;同协议需要改写时按 SSE frame 解析 `data` JSON仅改写 `model`;跨协议继续使用 StreamDecoder → CanonicalStreamConverter → StreamEncoder。
**错误边界**:网关层代理错误返回 `{"error":"...","code":"..."}`。已收到上游 HTTP 响应时,非 2xx status、过滤 hop-by-hop header 后的 headers 和 body 直接透传;没有收到上游响应的连接/DNS/TLS/超时错误返回 `UPSTREAM_UNAVAILABLE`
### 管理接口
#### 供应商管理
@@ -509,7 +544,7 @@ GET /anthropic/v1/models
- Anthropic 协议:配置到域名,不包含版本路径,如 `https://api.anthropic.com`
**对外 URL 格式**
- OpenAI 协议:`/{protocol}/{endpoint}`,如 `/openai/chat/completions``/openai/models``/openai/embeddings`
- OpenAI 协议:`/{protocol}/v1/{endpoint}`,如 `/openai/v1/chat/completions``/openai/v1/models``/openai/v1/embeddings`
- Anthropic 协议:`/{protocol}/v1/{endpoint}`,如 `/anthropic/v1/messages``/anthropic/v1/models`
#### 模型管理
@@ -551,6 +586,20 @@ GET /anthropic/v1/models
查询参数:`provider_id``model_name``start_date`YYYY-MM-DD`end_date``group_by`provider/model/date
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息
响应字段来源于构建阶段注入的 `buildinfo` 元数据:
```json
{
"version": "0.1.0",
"commit": "abc1234",
"build_time": "2026-05-05T00:00:00Z"
}
```
#### 健康检查
- `GET /health` - 返回 `{"status": "ok"}`
@@ -558,9 +607,12 @@ GET /anthropic/v1/models
## 开发
```bash
make build # 构建
make lint # 代码检查
make deps # 整理依赖
make build # 构建 backend/bin/server
make run # 运行后端服务
make lint # 代码检查
make clean # 清理 backend 构建产物
go mod tidy # 整理依赖
go generate ./... # 刷新 mock 等生成代码
```
环境要求Go 1.26 或更高版本

View File

@@ -4,25 +4,35 @@ package main
import (
"fmt"
"os/exec"
"strings"
"go.uber.org/zap"
)
func showError(title, message string) {
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`,
escapeAppleScript(message), escapeAppleScript(title))
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
dialogLogger().Warn("显示错误对话框失败", zap.Error(err))
}
}
func showAbout() {
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`,
escapeAppleScript(aboutMessage()), escapeAppleScript(appAboutTitle))
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
dialogLogger().Warn("显示关于对话框失败", zap.Error(err))
func platformStartupChannels(runner commandRunner) []promptChannel {
return []promptChannel{
{
name: "macos-notification",
available: func() error {
_, err := runner.LookPath("osascript")
return err
},
run: func(req promptRequest) error {
script := fmt.Sprintf(`display notification "%s" with title "%s" subtitle "%s"`,
escapeAppleScript(req.message), escapeAppleScript(req.title), escapeAppleScript(req.subtitle))
return runner.Run(promptCommandTimeout, nil, "osascript", "-e", script)
},
},
{
name: "macos-alert",
available: func() error {
_, err := runner.LookPath("osascript")
return err
},
run: func(req promptRequest) error {
script := fmt.Sprintf(`display alert "%s" message "%s" as critical buttons {"OK"} default button "OK"`,
escapeAppleScript(req.title), escapeAppleScript(req.message))
return runner.Run(promptCommandTimeout, nil, "osascript", "-e", script)
},
},
}
}

View File

@@ -0,0 +1,46 @@
//go:build darwin
package main
import (
"strings"
"testing"
)
func TestDarwinStartupChannelsBuildNotificationAndAlert(t *testing.T) {
runner := &fakeCommandRunner{paths: map[string]bool{"osascript": true}}
channels := platformStartupChannels(runner)
if len(channels) != 2 {
t.Fatalf("macOS 应有 notification 和 alert 两级通道,实际: %d", len(channels))
}
req := promptRequest{title: "Nex 启动失败", subtitle: "config", message: "路径 C:\\tmp 包含 \"quote\""}
for _, channel := range channels {
if err := channel.available(); err != nil {
t.Fatalf("通道 %s 应可用: %v", channel.name, err)
}
if err := channel.run(req); err != nil {
t.Fatalf("通道 %s 执行失败: %v", channel.name, err)
}
}
if len(runner.calls) != 2 {
t.Fatalf("应执行两次 osascript实际: %d", len(runner.calls))
}
if runner.calls[0].name != "osascript" || runner.calls[0].args[0] != "-e" {
t.Fatalf("notification 命令参数错误: %#v", runner.calls[0])
}
if script := runner.calls[0].args[1]; !strings.Contains(script, "display notification") || !strings.Contains(script, `\\tmp`) || !strings.Contains(script, `\"quote\"`) {
t.Fatalf("notification AppleScript 未正确构造或转义: %s", script)
}
if script := runner.calls[1].args[1]; !strings.Contains(script, "display alert") || !strings.Contains(script, "as critical") {
t.Fatalf("alert AppleScript 未使用 critical 告警: %s", script)
}
}
func TestEscapeAppleScript(t *testing.T) {
got := escapeAppleScript(`C:\tmp "quote"`)
if !strings.Contains(got, `C:\\tmp`) || !strings.Contains(got, `\"quote\"`) {
t.Fatalf("AppleScript 转义结果错误: %s", got)
}
}

View File

@@ -3,8 +3,9 @@
package main
import (
"errors"
"fmt"
"os/exec"
"os"
"sync"
)
@@ -12,74 +13,99 @@ type dialogToolType int
const (
toolNone dialogToolType = iota
toolZenity
toolKdialog
toolNotifySend
toolKdialogPassive
toolZenity
toolKdialogError
toolXmessage
)
var (
dialogTool dialogToolType
dialogToolOnce sync.Once
dialogTools map[string]bool
dialogToolOnce sync.Once
dialogToolNames = []string{"notify-send", "kdialog", "zenity", "xmessage"}
)
func init() {
dialogToolOnce.Do(detectDialogTool)
dialogToolOnce.Do(func() { detectDialogTools(defaultCommandRunner{}) })
}
func detectDialogTool() {
tools := []struct {
name string
typ dialogToolType
}{
{"zenity", toolZenity},
{"kdialog", toolKdialog},
{"notify-send", toolNotifySend},
{"xmessage", toolXmessage},
func platformStartupChannels(runner commandRunner) []promptChannel {
return []promptChannel{
linuxCommandChannel("notify-send", toolNotifySend, runner, linuxHasGraphicalSessionAndDBus, func(req promptRequest) []string {
return []string{"-u", "critical", "-a", appName, "-i", "nex", req.title, req.message}
}),
linuxCommandChannel("kdialog", toolKdialogPassive, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
return []string{"--title", req.title, "--passivepopup", req.message, "10"}
}),
linuxCommandChannel("zenity", toolZenity, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
return []string{"--error", fmt.Sprintf("--title=%s", req.title), fmt.Sprintf("--text=%s", req.message)}
}),
linuxCommandChannel("kdialog", toolKdialogError, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
return []string{"--title", req.title, "--error", req.message}
}),
linuxCommandChannel("xmessage", toolXmessage, runner, linuxHasX11Display, func(req promptRequest) []string {
return []string{"-center", "-buttons", "OK:0", "-default", "OK", fmt.Sprintf("%s: %s", req.title, req.message)}
}),
}
}
for _, tool := range tools {
if _, err := exec.LookPath(tool.name); err == nil {
dialogTool = tool.typ
return
func detectDialogTools(runner commandRunner) {
dialogTools = make(map[string]bool, len(dialogToolNames))
for _, name := range dialogToolNames {
_, err := runner.LookPath(name)
dialogTools[name] = err == nil
}
}
func linuxCommandChannel(name string, typ dialogToolType, runner commandRunner, environmentOK func() error, args func(promptRequest) []string) promptChannel {
return promptChannel{
name: fmt.Sprintf("linux-%s-%d", name, typ),
available: func() error {
if err := linuxCommandAvailable(runner, name); err != nil {
return err
}
return environmentOK()
},
run: func(req promptRequest) error {
return runner.Run(promptCommandTimeout, nil, name, args(req)...)
},
}
}
func linuxCommandAvailable(runner commandRunner, name string) error {
if _, ok := runner.(defaultCommandRunner); ok {
dialogToolOnce.Do(func() { detectDialogTools(runner) })
if dialogTools[name] {
return nil
}
return fmt.Errorf("%s 不可用", name)
}
dialogTool = toolNone
_, err := runner.LookPath(name)
return err
}
func showError(title, message string) {
switch dialogTool {
case toolZenity:
exec.Command("zenity", "--error",
fmt.Sprintf("--title=%s", title),
fmt.Sprintf("--text=%s", message)).Run()
case toolKdialog:
exec.Command("kdialog", "--error", message, "--title", title).Run()
case toolNotifySend:
exec.Command("notify-send", "-u", "critical", title, message).Run()
case toolXmessage:
exec.Command("xmessage", "-center",
fmt.Sprintf("%s: %s", title, message)).Run()
default:
dialogLogger().Error("无法显示错误对话框")
func linuxHasGraphicalSession() error {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return errors.New("缺少图形会话")
}
return nil
}
func showAbout() {
switch dialogTool {
case toolZenity:
exec.Command("zenity", "--info",
fmt.Sprintf("--title=%s", appAboutTitle),
fmt.Sprintf("--text=%s", aboutMessage())).Run()
case toolKdialog:
exec.Command("kdialog", "--msgbox", aboutMessage(), "--title", appAboutTitle).Run()
case toolNotifySend:
exec.Command("notify-send", appAboutTitle, aboutMessage()).Run()
case toolXmessage:
exec.Command("xmessage", "-center",
fmt.Sprintf("%s: %s", appAboutTitle, aboutMessage())).Run()
default:
dialogLogger().Info(appAboutTitle)
func linuxHasGraphicalSessionAndDBus() error {
if err := linuxHasGraphicalSession(); err != nil {
return err
}
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
return errors.New("缺少 DBus session bus")
}
return nil
}
func linuxHasX11Display() error {
if os.Getenv("DISPLAY") == "" {
return errors.New("缺少 X11 DISPLAY")
}
return nil
}

View File

@@ -0,0 +1,61 @@
//go:build linux
package main
import "testing"
func TestLinuxStartupChannelsPriorityAndArguments(t *testing.T) {
t.Setenv("DISPLAY", ":0")
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/dbus")
runner := &fakeCommandRunner{paths: map[string]bool{
"notify-send": true,
"kdialog": true,
"zenity": true,
"xmessage": true,
}}
channels := platformStartupChannels(runner)
if len(channels) != 5 {
t.Fatalf("Linux 应有 5 个 UI 通道,实际: %d", len(channels))
}
req := promptRequest{title: "Nex 启动失败", message: "端口被占用"}
for _, channel := range channels {
if err := channel.available(); err != nil {
t.Fatalf("通道 %s 应可用: %v", channel.name, err)
}
if err := channel.run(req); err != nil {
t.Fatalf("通道 %s 执行失败: %v", channel.name, err)
}
}
wantNames := []string{"notify-send", "kdialog", "zenity", "kdialog", "xmessage"}
for i, want := range wantNames {
if got := runner.calls[i].name; got != want {
t.Fatalf("第 %d 个命令 = %s, want %s", i, got, want)
}
}
if got := runner.calls[0].args; len(got) < 2 || got[0] != "-u" || got[1] != "critical" {
t.Fatalf("notify-send 应使用 critical 参数,实际: %#v", got)
}
if got := runner.calls[1].args; len(got) < 3 || got[2] != "--passivepopup" {
t.Fatalf("kdialog 第一跳应使用 passivepopup实际: %#v", got)
}
if got := runner.calls[2].args; len(got) < 1 || got[0] != "--error" {
t.Fatalf("zenity 应使用 --error实际: %#v", got)
}
if got := runner.calls[4].args; len(got) < 1 || got[0] != "-center" {
t.Fatalf("xmessage 应居中显示,实际: %#v", got)
}
}
func TestLinuxNotifySendRequiresDBus(t *testing.T) {
t.Setenv("DISPLAY", ":0")
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "")
runner := &fakeCommandRunner{paths: map[string]bool{"notify-send": true}}
channels := platformStartupChannels(runner)
if err := channels[0].available(); err == nil {
t.Fatal("notify-send 缺少 DBus session bus 时应不可用")
}
}

View File

@@ -3,35 +3,131 @@
package main
import (
"encoding/base64"
"errors"
"fmt"
"syscall"
"unicode/utf16"
"unsafe"
)
const (
MB_ICONERROR = 0x10
MB_ICONINFORMATION = 0x40
mbOK = 0x00000000
mbIconError = 0x10
mbIconInformation = 0x40
mbTaskModal = 0x00002000
mbSetForeground = 0x00010000
mbTopMost = 0x00040000
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procMessageBoxW = user32.NewProc("MessageBoxW")
callMessageBoxW = func(hwnd, text, caption, flags uintptr) (uintptr, error) {
ret, _, err := procMessageBoxW.Call(hwnd, text, caption, flags)
return ret, err
}
)
func showError(title, message string) {
messageBox(title, message, MB_ICONERROR)
func platformStartupChannels(runner commandRunner) []promptChannel {
return []promptChannel{
{
name: "windows-toast",
available: func() error {
_, err := findPowerShell(runner)
return err
},
run: func(req promptRequest) error {
name, err := findPowerShell(runner)
if err != nil {
return err
}
return runner.Run(promptCommandTimeout, []string{
"NEX_TOAST_TITLE=" + req.title,
"NEX_TOAST_BODY=" + req.message,
}, name, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShellCommand(windowsToastScript()))
},
},
{
name: "windows-messagebox",
available: func() error {
return messageBoxAvailable()
},
run: func(req promptRequest) error {
return messageBox(req.title, req.message, messageBoxStartupFlags())
},
},
}
}
func showAbout() {
messageBox(appAboutTitle, aboutMessage(), MB_ICONINFORMATION)
func findPowerShell(runner commandRunner) (string, error) {
for _, name := range []string{"powershell.exe", "powershell"} {
if _, err := runner.LookPath(name); err == nil {
return name, nil
}
}
return "", fmt.Errorf("PowerShell 不可用")
}
func messageBox(title, message string, flags uint) {
titlePtr, _ := syscall.UTF16PtrFromString(title)
messagePtr, _ := syscall.UTF16PtrFromString(message)
procMessageBoxW.Call(
func windowsToastScript() string {
return `$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)
$texts = $xml.GetElementsByTagName('text')
$texts.Item(0).AppendChild($xml.CreateTextNode($env:NEX_TOAST_TITLE)) | Out-Null
$texts.Item(1).AppendChild($xml.CreateTextNode($env:NEX_TOAST_BODY)) | Out-Null
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Nex').Show($toast)`
}
func encodePowerShellCommand(script string) string {
encoded := utf16.Encode([]rune(script))
buf := make([]byte, 0, len(encoded)*2)
for _, value := range encoded {
buf = append(buf, byte(value), byte(value>>8))
}
return base64.StdEncoding.EncodeToString(buf)
}
func messageBoxAvailable() error {
if _, err := syscall.UTF16PtrFromString("Nex"); err != nil {
return err
}
if _, err := syscall.UTF16PtrFromString("test"); err != nil {
return err
}
return procMessageBoxW.Find()
}
func messageBoxStartupFlags() uint {
return mbOK | mbIconError | mbTaskModal | mbSetForeground | mbTopMost
}
func messageBox(title, message string, flags uint) error {
titlePtr, err := syscall.UTF16PtrFromString(title)
if err != nil {
return err
}
messagePtr, err := syscall.UTF16PtrFromString(message)
if err != nil {
return err
}
ret, callErr := callMessageBoxW(
0,
uintptr(unsafe.Pointer(messagePtr)),
uintptr(unsafe.Pointer(titlePtr)),
uintptr(flags),
)
if ret != 0 {
return nil
}
if callErr != nil && !errors.Is(callErr, syscall.Errno(0)) {
return callErr
}
return fmt.Errorf("MessageBoxW 调用失败")
}

View File

@@ -0,0 +1 @@
1 ICON "../../../assets/icon.ico"

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"io/fs"
"net"
@@ -25,11 +26,12 @@ import (
"nex/backend/internal/provider"
"nex/backend/internal/repository"
"nex/backend/internal/service"
"nex/backend/pkg/buildinfo"
"github.com/getlantern/systray"
"github.com/gin-gonic/gin"
"github.com/gofrs/flock"
"go.uber.org/zap"
"gorm.io/gorm"
pkgLogger "nex/backend/pkg/logger"
)
@@ -39,37 +41,79 @@ var (
zapLogger *zap.Logger
shutdownCtx context.Context
shutdownCancel context.CancelFunc
desktopHooks = defaultDesktopRuntimeHooks()
)
type singletonLocker interface {
Lock() error
Unlock() error
}
type desktopRuntimeHooks struct {
loadConfig func() (*config.Config, config.ConfigMetadata, error)
newLock func(string) singletonLocker
listen func(int) (net.Listener, error)
upgradeLogger func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error)
initDB func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error)
closeDB func(*gorm.DB)
registerAdapters func(conversion.AdapterRegistry) error
setupStaticFiles func(*gin.Engine) error
startServer func(*http.Server, net.Listener, chan<- error, *zap.Logger)
setupSystray func(int, <-chan error) error
}
func defaultDesktopRuntimeHooks() desktopRuntimeHooks {
return desktopRuntimeHooks{
loadConfig: config.LoadDesktopConfigWithMetadata,
newLock: func(lockPath string) singletonLocker { return NewSingletonLock(lockPath) },
listen: listenDesktopPort,
upgradeLogger: pkgLogger.Upgrade,
initDB: database.Init,
closeDB: database.Close,
registerAdapters: registerDesktopAdapters,
setupStaticFiles: setupStaticFiles,
startServer: startDesktopServer,
setupSystray: setupSystray,
}
}
func main() {
port := 9826
minimalLogger := pkgLogger.NewMinimal()
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行")
showError(appName, "已有 Nex 实例运行")
if err := runDesktop(minimalLogger); err != nil {
reportStartupFailure(err, dialogLogger())
os.Exit(1)
}
}
func runDesktop(minimalLogger *zap.Logger) error {
if minimalLogger == nil {
minimalLogger = pkgLogger.NewMinimal()
}
cfg, cfgMeta, err := desktopHooks.loadConfig()
if err != nil {
return newStartupError(phaseConfig, desktopConfigErrorMessage(getDesktopConfigPath(), err), err)
}
port := cfg.Server.Port
singleLock := desktopHooks.newLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil {
return newStartupError(phaseSingleton, "已有 Nex 实例运行", err)
}
defer func() {
if err := singleLock.Unlock(); err != nil {
minimalLogger.Warn("释放实例锁失败", zap.Error(err))
}
}()
if err := checkPortAvailable(port); err != nil {
minimalLogger.Error("端口不可用", zap.Error(err))
showError(appName, err.Error())
return
}
cfg, err := config.LoadConfig()
listener, err := desktopHooks.listen(port)
if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err))
return newStartupError(phasePort, desktopPortUnavailableMessage(port), err)
}
defer listener.Close()
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
zapLogger, err = desktopHooks.upgradeLogger(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
@@ -78,7 +122,7 @@ func main() {
Compress: cfg.Log.Compress,
})
if err != nil {
minimalLogger.Fatal("初始化日志失败", zap.Error(err))
return newStartupError(phaseLogger, fmt.Sprintf("初始化日志失败\n\n日志目录: %s\n\n请检查目录权限或磁盘空间", cfg.Log.Path), err)
}
defer func() {
if err := zapLogger.Sync(); err != nil {
@@ -88,11 +132,17 @@ func main() {
cfg.PrintSummary(zapLogger)
db, err := database.Init(&cfg.Database, zapLogger)
db, err := desktopHooks.initDB(&cfg.Database, zapLogger)
if err != nil {
zapLogger.Fatal("初始化数据库失败", zap.Error(err))
phase := phaseDatabase
message := fmt.Sprintf("数据库初始化失败\n\n请检查数据库配置、文件权限或连接状态\n\n%v", err)
if errors.Is(err, database.ErrMigration) {
phase = phaseMigration
message = fmt.Sprintf("数据库迁移失败\n\n请查看日志或检查数据库迁移权限\n\n%v", err)
}
return newStartupError(phase, message, err)
}
defer database.Close(db)
defer desktopHooks.closeDB(db)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
@@ -115,11 +165,8 @@ func main() {
statsService := service.NewStatsService(statsRepo, statsBuffer)
registry := conversion.NewMemoryRegistry()
if err := registry.Register(openai.NewAdapter()); err != nil {
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err))
}
if err := registry.Register(anthropic.NewAdapter()); err != nil {
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
if err := desktopHooks.registerAdapters(registry); err != nil {
return newStartupError(phaseAdapter, startupInternalErrorMessage(), err)
}
engine := conversion.NewConversionEngine(registry, zapLogger)
@@ -129,6 +176,8 @@ func main() {
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -138,37 +187,65 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
setupStaticFiles(r)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
if err := desktopHooks.setupStaticFiles(r); err != nil {
return newStartupError(phaseStaticResource, startupInternalErrorMessage(), err)
}
server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Addr: desktopListenAddr(port),
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
shutdownCtx, shutdownCancel = context.WithCancel(context.Background())
defer doShutdown()
go func() {
zapLogger.Info("AI Gateway 启动", zap.String("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
zapLogger.Fatal("服务器启动失败", zap.Error(err))
}
}()
serverErrCh := make(chan error, 1)
desktopHooks.startServer(server, listener, serverErrCh, zapLogger)
select {
case err := <-serverErrCh:
return newStartupError(phaseServer, startupServerErrorMessage(), err)
case <-time.After(50 * time.Millisecond):
}
go func() {
time.Sleep(500 * time.Millisecond)
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
zapLogger.Warn("无法打开浏览器", zap.Error(err))
}
}()
if err := desktopHooks.setupSystray(port, serverErrCh); err != nil {
return err
}
setupSystray(port)
select {
case err := <-serverErrCh:
return newStartupError(phaseServer, startupServerErrorMessage(), err)
default:
return nil
}
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
r.Any("/v1/*path", proxyHandler.HandleProxy)
func registerDesktopAdapters(registry conversion.AdapterRegistry) error {
if err := registry.Register(openai.NewAdapter()); err != nil {
return err
}
return registry.Register(anthropic.NewAdapter())
}
func startDesktopServer(server *http.Server, listener net.Listener, serverErrCh chan<- error, logger *zap.Logger) {
go func() {
logger.Info("AI Gateway 启动",
zap.String("addr", server.Addr),
zap.String("version", buildinfo.Version()),
zap.String("commit", buildinfo.Commit()),
zap.String("build_time", buildinfo.BuildTime()))
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
serverErrCh <- err
}
}()
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
r.GET("/api/version", versionHandler.GetVersion)
providers := r.Group("/api/providers")
{
@@ -194,17 +271,38 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
stats.GET("/aggregate", statsHandler.AggregateStats)
}
settings := r.Group("/api/settings")
{
settings.GET("/startup", settingsHandler.GetStartupSettings)
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
}
func setupStaticFiles(r *gin.Engine) {
distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
if err != nil {
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
func withProtocol(protocol string, next gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
c.Params = append(c.Params, gin.Param{Key: "protocol", Value: protocol})
next(c)
}
}
func setupStaticFiles(r *gin.Engine) error {
distFS, err := frontendDistFS()
if err != nil {
return err
}
setupStaticFilesWithFS(r, distFS)
return nil
}
func frontendDistFS() (fs.FS, error) {
return fs.Sub(embedfs.FrontendDist, "frontend-dist")
}
func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
getContentType := func(path string) string {
if strings.HasSuffix(path, ".js") {
return "application/javascript"
@@ -237,20 +335,23 @@ func setupStaticFiles(r *gin.Engine) {
c.Data(200, getContentType(filepath), data)
})
r.GET("/favicon.svg", func(c *gin.Context) {
data, err := fs.ReadFile(distFS, "favicon.svg")
r.GET("/icon.png", func(c *gin.Context) {
data, err := fs.ReadFile(distFS, "icon.png")
if err != nil {
c.Status(404)
return
}
c.Data(200, "image/svg+xml", data)
c.Data(200, "image/png", data)
})
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/openai/") ||
strings.HasPrefix(path, "/anthropic/") ||
path == "/openai" ||
path == "/anthropic" ||
strings.HasPrefix(path, "/health") {
c.JSON(404, gin.H{"error": "not found"})
return
@@ -265,51 +366,6 @@ func setupStaticFiles(r *gin.Engine) {
})
}
func setupSystray(port int) {
systray.Run(func() {
var icon []byte
var err error
if runtime.GOOS == "windows" {
icon, err = embedfs.Assets.ReadFile("assets/icon.ico")
} else {
icon, err = embedfs.Assets.ReadFile("assets/icon.png")
}
if err != nil {
zapLogger.Error("无法加载托盘图标", zap.Error(err))
}
systray.SetIcon(icon)
systray.SetTooltip(appTooltip)
mOpen := systray.AddMenuItem("打开管理界面", "在浏览器中打开")
systray.AddSeparator()
mStatus := systray.AddMenuItem("状态: 运行中", "")
mStatus.Disable()
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
mPort.Disable()
systray.AddSeparator()
mAbout := systray.AddMenuItem("关于", "")
systray.AddSeparator()
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
go func() {
for {
select {
case <-mOpen.ClickedCh:
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
zapLogger.Warn("打开浏览器失败", zap.Error(err))
}
case <-mAbout.ClickedCh:
showAbout()
case <-mQuit.ClickedCh:
doShutdown()
systray.Quit()
return
}
}
}()
}, nil)
}
func doShutdown() {
if zapLogger != nil {
zapLogger.Info("正在关闭服务器...")
@@ -328,13 +384,36 @@ func doShutdown() {
}
}
func checkPortAvailable(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
func getDesktopConfigPath() string {
configDir, err := config.GetConfigDir()
if err != nil {
return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
return "~/.nex/config.yaml"
}
ln.Close()
return nil
return filepath.Join(configDir, "config.yaml")
}
func desktopConfigErrorMessage(configPath string, err error) string {
return fmt.Sprintf("加载配置失败\n\n配置文件: %s\n\n%v", configPath, err)
}
func desktopListenAddr(port int) string {
return fmt.Sprintf(":%d", port)
}
func desktopURL(port int) string {
return fmt.Sprintf("http://localhost:%d", port)
}
func desktopPortMenuTitle(port int) string {
return fmt.Sprintf("端口: %d", port)
}
func listenDesktopPort(port int) (net.Listener, error) {
return net.Listen("tcp", desktopListenAddr(port))
}
func desktopPortUnavailableMessage(port int) string {
return fmt.Sprintf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
}
type SingletonLock struct {

View File

@@ -3,17 +3,104 @@
package main
import (
"errors"
"syscall"
"testing"
)
func TestMessageBoxW_WindowsOnly(t *testing.T) {
messageBox("测试标题", "测试消息", MB_ICONINFORMATION)
func withMessageBoxW(t *testing.T, fn func(hwnd, text, caption, flags uintptr) (uintptr, error)) {
t.Helper()
old := callMessageBoxW
callMessageBoxW = fn
t.Cleanup(func() {
callMessageBoxW = old
})
}
func TestMessageBoxW_WindowsOnly_InvalidUTF16(t *testing.T) {
err := messageBox("bad\x00title", "测试消息", mbIconInformation)
if err == nil {
t.Fatal("包含 NUL 字符时应该返回错误")
}
}
func TestMessageBoxW_WindowsOnly_SuccessIgnoresLastError(t *testing.T) {
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
return 1, syscall.Errno(123)
})
if err := messageBox("测试标题", "测试消息", mbIconInformation); err != nil {
t.Fatalf("MessageBoxW 返回成功时应忽略 last error: %v", err)
}
}
func TestMessageBoxW_WindowsOnly_FailureUsesReturnValue(t *testing.T) {
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
return 0, syscall.Errno(5)
})
err := messageBox("测试标题", "测试消息", mbIconInformation)
if !errors.Is(err, syscall.Errno(5)) {
t.Fatalf("MessageBoxW 返回 0 时应返回调用错误: %v", err)
}
}
func TestShowError_WindowsBranch(t *testing.T) {
old := buildPromptChannels
buildPromptChannels = func(commandRunner) []promptChannel {
return []promptChannel{{
name: "fake-failed-channel",
available: func() error { return nil },
run: func(promptRequest) error { return syscall.Errno(5) },
}}
}
t.Cleanup(func() { buildPromptChannels = old })
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("showError 不应因 MessageBoxW 失败而 panic: %v", recovered)
}
}()
showError("测试错误", "这是一条测试错误消息")
}
func TestShowAbout_WindowsBranch(t *testing.T) {
showAbout()
func TestMessageBoxW_WindowsOnly_StartupFlags(t *testing.T) {
var gotFlags uintptr
withMessageBoxW(t, func(_, _, _, flags uintptr) (uintptr, error) {
gotFlags = flags
return 1, syscall.Errno(0)
})
if err := messageBox("测试标题", "测试消息", messageBoxStartupFlags()); err != nil {
t.Fatalf("MessageBoxW 应成功: %v", err)
}
for _, flag := range []uint{mbIconError, mbTaskModal, mbSetForeground, mbTopMost} {
if gotFlags&uintptr(flag) == 0 {
t.Fatalf("startup flags 缺少 0x%x实际: 0x%x", flag, gotFlags)
}
}
}
func TestWindowsStartupChannelsUseToastBeforeMessageBox(t *testing.T) {
runner := &fakeCommandRunner{paths: map[string]bool{"powershell.exe": true}}
channels := platformStartupChannels(runner)
if len(channels) != 2 {
t.Fatalf("Windows 应有 Toast 和 MessageBox 两级通道,实际: %d", len(channels))
}
if channels[0].name != "windows-toast" || channels[1].name != "windows-messagebox" {
t.Fatalf("Windows 通道顺序错误: %s, %s", channels[0].name, channels[1].name)
}
if err := channels[0].available(); err != nil {
t.Fatalf("PowerShell 存在时 Toast 通道应可用: %v", err)
}
if err := channels[0].run(promptRequest{title: "Nex 启动失败", message: "端口被占用"}); err != nil {
t.Fatalf("Toast fake runner 应执行成功: %v", err)
}
if len(runner.calls) != 1 || runner.calls[0].name != "powershell.exe" {
t.Fatalf("Toast 应调用 powershell.exe实际: %#v", runner.calls)
}
}

View File

@@ -3,11 +3,7 @@ package main
const (
appName = "Nex"
appTooltip = appName
appAboutTitle = "关于 " + appName
appDescription = "AI Gateway - 统一的大模型 API 网关"
appWebsite = "https://github.com/nex/gateway"
// #nosec G101 -- 项目官网地址不是凭据
appWebsite = "https://github.com/nex/gateway"
)
func aboutMessage() string {
return appName + "\n\n" + appDescription + "\n\n" + appWebsite
}

View File

@@ -2,13 +2,6 @@ package main
import "testing"
func TestAboutMessage(t *testing.T) {
expected := "Nex\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway"
if got := aboutMessage(); got != expected {
t.Fatalf("aboutMessage() = %q, want %q", got, expected)
}
}
func TestDesktopMetadata(t *testing.T) {
if appName != "Nex" {
t.Fatalf("appName = %q, want %q", appName, "Nex")
@@ -17,8 +10,4 @@ func TestDesktopMetadata(t *testing.T) {
if appTooltip != appName {
t.Fatalf("appTooltip = %q, want %q", appTooltip, appName)
}
if appAboutTitle != "关于 Nex" {
t.Fatalf("appAboutTitle = %q, want %q", appAboutTitle, "关于 Nex")
}
}

View File

@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
"nex/backend/internal/config"
"nex/backend/internal/database"
"go.uber.org/zap"
)
func TestDesktop_InitMigrationsWithoutSourceTree(t *testing.T) {
tmpDir := t.TempDir()
origDir, err := os.Getwd()
if err == nil {
defer func() {
if chdirErr := os.Chdir(origDir); chdirErr != nil {
t.Logf("无法恢复工作目录: %v", chdirErr)
}
}()
}
if chdirErr := os.Chdir(tmpDir); chdirErr != nil {
t.Skipf("无法切换到临时目录: %v", chdirErr)
}
cfg := &config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(tmpDir, "nex-test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := database.Init(cfg, zapLogger)
if err != nil {
t.Fatalf("在无源码目录环境下数据库初始化应成功,但返回错误: %v", err)
}
database.Close(db)
}

View File

@@ -4,66 +4,66 @@ import (
"errors"
"net"
"net/http"
"strings"
"testing"
"time"
)
func TestCheckPortAvailable(t *testing.T) {
port := 19826
err := checkPortAvailable(port)
func TestListenDesktopPortReturnsReusableListener(t *testing.T) {
listener, err := listenDesktopPort(0)
if err != nil {
t.Fatalf("端口 %d 应该可用: %v", port, err)
}
t.Log("端口可用测试通过")
}
func TestCheckPortOccupied(t *testing.T) {
port := 19827
listener, err := net.Listen("tcp", ":19827")
if err != nil {
t.Fatalf("无法启动测试服务器: %v", err)
t.Fatalf("listener-first 应直接获取配置端口 listener: %v", err)
}
defer listener.Close()
time.Sleep(100 * time.Millisecond)
err = checkPortAvailable(port)
if err == nil {
t.Fatal("端口被占用时应该返回错误")
}
t.Log("端口占用检测测试通过")
}
func TestCheckPortAvailableAfterClose(t *testing.T) {
port := 19828
listener, err := net.Listen("tcp", "127.0.0.1:19828")
if err != nil {
t.Fatalf("无法启动测试服务器: %v", err)
}
server := &http.Server{ReadHeaderTimeout: time.Second}
defer server.Close()
done := make(chan struct{})
go func() {
defer close(done)
err := server.Serve(listener)
if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) {
t.Errorf("serve failed: %v", err)
t.Errorf("使用同一个 listener 启动 server 失败: %v", err)
}
}()
time.Sleep(100 * time.Millisecond)
listener.Close()
time.Sleep(100 * time.Millisecond)
err = checkPortAvailable(port)
if err != nil {
t.Fatalf("端口关闭后应该可用: %v", err)
if err := server.Close(); err != nil {
t.Fatalf("关闭测试 server 失败: %v", err)
}
<-done
}
func TestGetDesktopConfigPath(t *testing.T) {
path := getDesktopConfigPath()
if path == "" {
t.Fatal("getDesktopConfigPath 应返回非空路径")
}
if !strings.Contains(path, "config.yaml") {
t.Fatalf("路径应包含 config.yaml实际: %s", path)
}
t.Log("getDesktopConfigPath 测试通过")
}
func TestDesktopConfiguredPortHelpers(t *testing.T) {
port := 19830
if got := desktopListenAddr(port); got != ":19830" {
t.Fatalf("HTTP 监听地址应使用配置端口,实际: %s", got)
}
if got := desktopURL(port); got != "http://localhost:19830" {
t.Fatalf("浏览器 URL 应使用配置端口,实际: %s", got)
}
if got := desktopPortMenuTitle(port); got != "端口: 19830" {
t.Fatalf("托盘端口显示应使用配置端口,实际: %s", got)
}
}
func TestDesktopConfigErrorMessageContainsPathAndReason(t *testing.T) {
msg := desktopConfigErrorMessage("/tmp/nex/config.yaml", errors.New("yaml parse failed"))
if !strings.Contains(msg, "/tmp/nex/config.yaml") {
t.Fatalf("配置错误提示应包含配置路径,实际: %s", msg)
}
if !strings.Contains(msg, "yaml parse failed") {
t.Fatalf("配置错误提示应包含失败原因,实际: %s", msg)
}
t.Log("端口关闭后可用测试通过")
}

View File

@@ -0,0 +1,121 @@
package main
import (
"context"
"errors"
"io"
"os"
"os/exec"
"time"
"go.uber.org/zap"
)
const promptCommandTimeout = 5 * time.Second
type promptRequest struct {
title string
message string
subtitle string
}
type promptChannel struct {
name string
available func() error
run func(promptRequest) error
}
type commandRunner interface {
LookPath(file string) (string, error)
Run(timeout time.Duration, env []string, name string, args ...string) error
}
type defaultCommandRunner struct{}
var buildPromptChannels = platformStartupChannels
func (defaultCommandRunner) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (defaultCommandRunner) Run(timeout time.Duration, env []string, name string, args ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
if err := cmd.Run(); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return nil
}
func showError(title, message string) {
reportPrompt(promptRequest{title: title, message: message}, os.Stderr, dialogLogger())
}
func reportStartupFailure(err error, logger *zap.Logger) {
if err == nil {
return
}
var startupErr *startupError
if !errors.As(err, &startupErr) {
startupErr = newStartupError(phaseServer, startupServerErrorMessage(), err)
}
if logger == nil {
logger = dialogLogger()
}
logger.Error("desktop 启动失败",
zap.String("phase", startupErr.Phase()),
zap.Error(startupErr))
reportPrompt(promptRequest{
title: startupTitle(),
message: startupErr.UserMessage(),
subtitle: startupErr.Phase(),
}, os.Stderr, logger)
}
func reportPrompt(req promptRequest, fallback io.Writer, logger *zap.Logger) {
runPromptPipeline(req, buildPromptChannels(defaultCommandRunner{}), fallback, logger)
}
func runPromptPipeline(req promptRequest, channels []promptChannel, fallback io.Writer, logger *zap.Logger) {
if logger == nil {
logger = dialogLogger()
}
for _, channel := range channels {
if channel.available != nil {
if err := channel.available(); err != nil {
logger.Warn("提示通道不可用", zap.String("channel", channel.name), zap.Error(err))
continue
}
}
if err := channel.run(req); err != nil {
logger.Warn("提示通道执行失败", zap.String("channel", channel.name), zap.Error(err))
continue
}
return
}
writePromptFallback(fallback, req.title, req.message)
}
func writePromptFallback(w io.Writer, title, message string) {
if w == nil {
return
}
if _, err := io.WriteString(w, "错误: "+title+": "+message+"\n"); err != nil {
return
}
}

View File

@@ -0,0 +1,140 @@
package main
import (
"bytes"
"errors"
"fmt"
"os/exec"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type commandCall struct {
timeout time.Duration
env []string
name string
args []string
}
type fakeCommandRunner struct {
paths map[string]bool
runErrs map[string]error
calls []commandCall
}
func (r *fakeCommandRunner) LookPath(file string) (string, error) {
if r.paths[file] {
return "/usr/bin/" + file, nil
}
return "", exec.ErrNotFound
}
func (r *fakeCommandRunner) Run(timeout time.Duration, env []string, name string, args ...string) error {
r.calls = append(r.calls, commandCall{
timeout: timeout,
env: append([]string(nil), env...),
name: name,
args: append([]string(nil), args...),
})
if err := r.runErrs[name]; err != nil {
return err
}
return nil
}
func TestRunPromptPipelineFallbackOrder(t *testing.T) {
var calls []string
channels := []promptChannel{
{
name: "unavailable",
available: func() error {
calls = append(calls, "available-1")
return errors.New("missing")
},
run: func(promptRequest) error {
calls = append(calls, "run-1")
return nil
},
},
{
name: "failed",
available: func() error {
calls = append(calls, "available-2")
return nil
},
run: func(promptRequest) error {
calls = append(calls, "run-2")
return errors.New("failed")
},
},
{
name: "success",
available: func() error {
calls = append(calls, "available-3")
return nil
},
run: func(promptRequest) error {
calls = append(calls, "run-3")
return nil
},
},
}
var fallback bytes.Buffer
runPromptPipeline(promptRequest{title: "Nex 启动失败", message: "启动失败"}, channels, &fallback, zap.NewNop())
want := []string{"available-1", "available-2", "run-2", "available-3", "run-3"}
if fmt.Sprint(calls) != fmt.Sprint(want) {
t.Fatalf("调用顺序 = %v, want %v", calls, want)
}
if fallback.Len() != 0 {
t.Fatalf("成功通道后不应写入 fallback实际: %s", fallback.String())
}
}
func TestRunPromptPipelineWritesFallback(t *testing.T) {
channels := []promptChannel{
{
name: "unavailable",
available: func() error { return errors.New("missing") },
run: func(promptRequest) error { return nil },
},
}
var fallback bytes.Buffer
runPromptPipeline(promptRequest{title: "Nex 启动失败", message: "端口被占用"}, channels, &fallback, zap.NewNop())
want := "错误: Nex 启动失败: 端口被占用\n"
if fallback.String() != want {
t.Fatalf("fallback = %q, want %q", fallback.String(), want)
}
}
func TestReportStartupFailureLogsRedactedError(t *testing.T) {
old := buildPromptChannels
buildPromptChannels = func(commandRunner) []promptChannel {
return []promptChannel{{name: "fake-success", run: func(promptRequest) error { return nil }}}
}
t.Cleanup(func() { buildPromptChannels = old })
core, logs := observer.New(zap.ErrorLevel)
logger := zap.New(core)
err := errors.New("数据库连接失败: nex:secret@tcp(localhost:3306)/nex password=secret api_key=sk-test")
reportStartupFailure(err, logger)
entries := logs.All()
if len(entries) != 1 {
t.Fatalf("应记录 1 条错误日志,实际: %d", len(entries))
}
fields := fmt.Sprint(entries[0].ContextMap())
for _, secret := range []string{"secret", "sk-test"} {
if strings.Contains(fields, secret) {
t.Fatalf("启动失败日志不应包含敏感信息 %q实际: %s", secret, fields)
}
}
}

View File

@@ -0,0 +1,44 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"nex/backend/internal/handler"
"github.com/gin-gonic/gin"
)
func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "desktop", true, ""))
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
})
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
if contentType := w.Header().Get("Content-Type"); contentType == "text/html; charset=utf-8" {
t.Fatalf("版本接口不应返回 SPA fallback HTML")
}
var result map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
for _, key := range []string{"version", "commit", "build_time"} {
if result[key] == "" {
t.Fatalf("响应缺少 %s 字段: %#v", key, result)
}
}
}

View File

@@ -0,0 +1,332 @@
package main
import (
"errors"
"fmt"
"net"
"net/http"
"path/filepath"
"sync/atomic"
"testing"
"time"
"nex/backend/internal/config"
"nex/backend/internal/conversion"
"nex/backend/internal/database"
pkgLogger "nex/backend/pkg/logger"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type fakeDesktopLock struct {
lockErr error
unlockCount atomic.Int32
}
func (l *fakeDesktopLock) Lock() error {
return l.lockErr
}
func (l *fakeDesktopLock) Unlock() error {
l.unlockCount.Add(1)
return nil
}
func (l *fakeDesktopLock) unlocked() bool {
return l.unlockCount.Load() > 0
}
type recordingListener struct {
net.Listener
closeCount atomic.Int32
}
func newRecordingListener(t *testing.T) *recordingListener {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("创建测试 listener 失败: %v", err)
}
return &recordingListener{Listener: listener}
}
func (l *recordingListener) Close() error {
l.closeCount.Add(1)
return l.Listener.Close()
}
func (l *recordingListener) closed() bool {
return l.closeCount.Load() > 0
}
func testDesktopConfig(t *testing.T) *config.Config {
t.Helper()
tmpDir := t.TempDir()
cfg := config.DefaultConfig()
cfg.Server.Port = 0
cfg.Database.Driver = "sqlite"
cfg.Database.Path = filepath.Join(tmpDir, "config.db")
cfg.Log.Path = filepath.Join(tmpDir, "log")
return cfg
}
func installDesktopTestHooks(t *testing.T, cfg *config.Config, mutate func(*desktopRuntimeHooks)) {
t.Helper()
oldHooks := desktopHooks
oldServer := server
oldLogger := zapLogger
oldShutdownCtx := shutdownCtx
oldShutdownCancel := shutdownCancel
server = nil
zapLogger = nil
shutdownCtx = nil
shutdownCancel = nil
hooks := defaultDesktopRuntimeHooks()
if cfg != nil {
hooks.loadConfig = func() (*config.Config, config.ConfigMetadata, error) {
return cfg, config.ConfigMetadata{ConfigPath: filepath.Join(t.TempDir(), "config.yaml")}, nil
}
}
hooks.upgradeLogger = func(_ *zap.Logger, _ pkgLogger.Config) (*zap.Logger, error) {
return zap.NewNop(), nil
}
hooks.setupStaticFiles = func(*gin.Engine) error { return nil }
hooks.startServer = func(*http.Server, net.Listener, chan<- error, *zap.Logger) {}
hooks.setupSystray = func(int, <-chan error) error { return nil }
if mutate != nil {
mutate(&hooks)
}
desktopHooks = hooks
t.Cleanup(func() {
if server != nil {
_ = server.Close()
}
desktopHooks = oldHooks
server = oldServer
zapLogger = oldLogger
shutdownCtx = oldShutdownCtx
shutdownCancel = oldShutdownCancel
})
}
func requireStartupPhase(t *testing.T, err error, want startupPhase) {
t.Helper()
if err == nil {
t.Fatalf("期望 %s 阶段启动错误,实际 nil", want)
}
var startupErr *startupError
if !errors.As(err, &startupErr) {
t.Fatalf("期望 startupError实际: %T %v", err, err)
}
if startupErr.phase != want {
t.Fatalf("phase = %s, want %s", startupErr.phase, want)
}
}
func TestRunDesktopConfigFailureReturnsConfigPhase(t *testing.T) {
installDesktopTestHooks(t, nil, func(h *desktopRuntimeHooks) {
h.loadConfig = func() (*config.Config, config.ConfigMetadata, error) {
return nil, config.ConfigMetadata{}, errors.New("yaml 解析失败")
}
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phaseConfig)
}
func TestRunDesktopSingletonFailurePrecedesPortListen(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{lockErr: errors.New("已有实例运行")}
listenCalled := false
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) {
listenCalled = true
return nil, errors.New("不应监听端口")
}
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phaseSingleton)
if listenCalled {
t.Fatal("单实例锁失败时不应继续监听端口")
}
}
func TestRunDesktopPortFailureUnlocksSingleton(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return nil, errors.New("bind failed") }
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phasePort)
if !lock.unlocked() {
t.Fatal("端口监听失败时应释放单实例锁")
}
}
func TestRunDesktopLoggerFailureClosesListenerAndUnlocks(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
listener := newRecordingListener(t)
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return listener, nil }
h.upgradeLogger = func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error) {
return nil, errors.New("log permission denied")
}
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, phaseLogger)
if !listener.closed() {
t.Fatal("日志初始化失败时应关闭 listener")
}
if !lock.unlocked() {
t.Fatal("日志初始化失败时应释放单实例锁")
}
}
func TestRunDesktopDatabaseFailureClassification(t *testing.T) {
tests := []struct {
name string
err error
want startupPhase
}{
{name: "database", err: errors.New("open failed"), want: phaseDatabase},
{name: "migration", err: fmt.Errorf("%w: %w", database.ErrMigration, errors.New("goose failed")), want: phaseMigration},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
listener := newRecordingListener(t)
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return listener, nil }
h.initDB = func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error) { return nil, tt.err }
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, tt.want)
if !listener.closed() {
t.Fatal("数据库失败时应关闭 listener")
}
if !lock.unlocked() {
t.Fatal("数据库失败时应释放单实例锁")
}
})
}
}
func TestRunDesktopInternalStartupFailurePhasesAndDatabaseCleanup(t *testing.T) {
tests := []struct {
name string
mutate func(*desktopRuntimeHooks)
want startupPhase
}{
{
name: "adapter",
mutate: func(h *desktopRuntimeHooks) {
h.registerAdapters = func(conversion.AdapterRegistry) error { return errors.New("adapter failed") }
},
want: phaseAdapter,
},
{
name: "static",
mutate: func(h *desktopRuntimeHooks) {
h.setupStaticFiles = func(*gin.Engine) error { return errors.New("missing frontend") }
},
want: phaseStaticResource,
},
{
name: "server",
mutate: func(h *desktopRuntimeHooks) {
h.startServer = func(_ *http.Server, _ net.Listener, errCh chan<- error, _ *zap.Logger) {
errCh <- errors.New("serve failed")
}
},
want: phaseServer,
},
{
name: "tray",
mutate: func(h *desktopRuntimeHooks) {
h.setupSystray = func(int, <-chan error) error {
return newStartupError(phaseTray, "托盘初始化失败", errors.New("tray failed"))
}
},
want: phaseTray,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testDesktopConfig(t)
lock := &fakeDesktopLock{}
listener := newRecordingListener(t)
closeDBCalled := false
installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) {
h.newLock = func(string) singletonLocker { return lock }
h.listen = func(int) (net.Listener, error) { return listener, nil }
h.closeDB = func(db *gorm.DB) {
closeDBCalled = true
database.Close(db)
}
tt.mutate(h)
})
err := runDesktop(zap.NewNop())
requireStartupPhase(t, err, tt.want)
if !closeDBCalled {
t.Fatal("数据库初始化后的启动失败应关闭数据库")
}
if !listener.closed() {
t.Fatal("数据库初始化后的启动失败应关闭 listener")
}
if !lock.unlocked() {
t.Fatal("数据库初始化后的启动失败应释放单实例锁")
}
})
}
}
func TestRunDesktopBrowserFailureRemainsNonFatal(t *testing.T) {
controller := newFakeTrayController()
notified := make(chan string, 1)
controller.run = func(onReady func(), _ func()) {
onReady()
<-controller.quitCh
}
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(string) error { return errors.New("no browser") },
notify: func(_, message string) {
notified <- message
controller.Quit()
},
logger: zap.NewNop(),
})
if err != nil {
t.Fatalf("浏览器打开失败不应导致 runSystray 返回 fatal: %v", err)
}
if got := <-notified; got == "" {
t.Fatal("浏览器打开失败应提示用户手动访问")
}
}

View File

@@ -0,0 +1,96 @@
package main
import (
"fmt"
"regexp"
)
type startupPhase string
const (
phaseConfig startupPhase = "config"
phaseSingleton startupPhase = "singleton"
phasePort startupPhase = "port"
phaseLogger startupPhase = "logger"
phaseDatabase startupPhase = "database"
phaseMigration startupPhase = "migration"
phaseAdapter startupPhase = "adapter"
phaseStaticResource startupPhase = "static"
phaseServer startupPhase = "server"
phaseTray startupPhase = "tray"
)
type startupError struct {
phase startupPhase
message string
cause error
}
func newStartupError(phase startupPhase, message string, cause error) *startupError {
return &startupError{
phase: phase,
message: redactSensitive(message),
cause: cause,
}
}
func (e *startupError) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return fmt.Sprintf("%s: %s", e.phase, e.message)
}
return fmt.Sprintf("%s: %s: %s", e.phase, e.message, redactSensitive(e.cause.Error()))
}
func (e *startupError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func (e *startupError) Phase() string {
if e == nil {
return ""
}
return string(e.phase)
}
func (e *startupError) UserMessage() string {
if e == nil {
return ""
}
return redactSensitive(e.message)
}
var sensitiveReplacers = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`(?i)(password\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
{regexp.MustCompile(`(?i)(api[_-]?key\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
{regexp.MustCompile(`(?i)(secret\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
{regexp.MustCompile(`([^\s:/]+):([^\s@]+)@tcp\(`), `${1}:<redacted>@tcp(`},
{regexp.MustCompile(`(://[^\s:/]+):([^\s@]+)@`), `${1}:<redacted>@`},
}
func redactSensitive(s string) string {
for _, replacer := range sensitiveReplacers {
s = replacer.pattern.ReplaceAllString(s, replacer.replacement)
}
return s
}
func startupTitle() string {
return appName + " 启动失败"
}
func startupServerErrorMessage() string {
return "后端服务启动失败\n\n请检查端口占用、网络权限或查看日志获取更多信息"
}
func startupInternalErrorMessage() string {
return "应用初始化失败\n\n请查看日志或重新安装应用"
}

View File

@@ -0,0 +1,40 @@
package main
import (
"errors"
"strings"
"testing"
)
func TestStartupErrorContainsPhaseAndCause(t *testing.T) {
cause := errors.New("底层失败")
err := newStartupError(phaseDatabase, "数据库初始化失败", cause)
if err.Phase() != "database" {
t.Fatalf("phase = %q, want database", err.Phase())
}
if !errors.Is(err, cause) {
t.Fatal("startupError 应保留底层 cause")
}
if !strings.Contains(err.Error(), "database") {
t.Fatalf("错误字符串应包含 phase实际: %s", err.Error())
}
}
func TestStartupErrorRedactsSensitiveUserMessage(t *testing.T) {
message := "数据库初始化失败: nex:secret@tcp(localhost:3306)/nex password=secret api_key=sk-test"
err := newStartupError(phaseDatabase, message, errors.New("cause password=secret api_key=sk-test"))
userMessage := err.UserMessage()
for _, secret := range []string{"secret", "sk-test"} {
if strings.Contains(userMessage, secret) {
t.Fatalf("用户提示不应包含敏感信息 %q实际: %s", secret, userMessage)
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("日志错误字符串不应包含敏感信息 %q实际: %s", secret, err.Error())
}
}
if !strings.Contains(userMessage, "<redacted>") {
t.Fatalf("用户提示应包含脱敏占位符,实际: %s", userMessage)
}
}

View File

@@ -1,12 +1,11 @@
package main
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"nex/embedfs"
"testing/fstest"
"github.com/gin-gonic/gin"
)
@@ -14,59 +13,14 @@ import (
func TestSetupStaticFiles(t *testing.T) {
gin.SetMode(gin.TestMode)
distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
if err != nil {
t.Skipf("跳过测试: 前端资源未构建: %v", err)
return
}
getContentType := func(path string) string {
if strings.HasSuffix(path, ".js") {
return "application/javascript"
}
if strings.HasSuffix(path, ".css") {
return "text/css"
}
if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
}
return "application/octet-stream"
}
r := gin.New()
r.GET("/assets/*filepath", func(c *gin.Context) {
filepath := c.Param("filepath")
data, err := fs.ReadFile(distFS, "assets"+filepath)
if err != nil {
c.Status(404)
return
}
c.Data(200, getContentType(filepath), data)
})
r.GET("/favicon.svg", func(c *gin.Context) {
data, err := fs.ReadFile(distFS, "favicon.svg")
if err != nil {
c.Status(404)
return
}
c.Data(200, "image/svg+xml", data)
})
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/health") {
c.JSON(404, gin.H{"error": "not found"})
return
}
data, err := fs.ReadFile(distFS, "index.html")
if err != nil {
c.Status(500)
return
}
c.Data(200, "text/html; charset=utf-8", data)
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
"icon.png": {Data: []byte("png")},
"assets/test.js": {Data: []byte("console.log('test')")},
"assets/test.css": {Data: []byte("body {}")},
"assets/test.svg": {Data: []byte("<svg></svg>")},
"assets/test.woff": {Data: []byte("font")},
})
t.Run("API 404", func(t *testing.T) {
@@ -79,6 +33,32 @@ func TestSetupStaticFiles(t *testing.T) {
}
})
t.Run("OpenAI proxy prefix 404", func(t *testing.T) {
req := httptest.NewRequest("GET", "/openai/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("期望状态码 404, 实际 %d", w.Code)
}
if !strings.Contains(w.Body.String(), "not found") {
t.Errorf("期望返回 API 风格错误,实际 %s", w.Body.String())
}
})
t.Run("Anthropic proxy prefix 404", func(t *testing.T) {
req := httptest.NewRequest("GET", "/anthropic/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("期望状态码 404, 实际 %d", w.Code)
}
if !strings.Contains(w.Body.String(), "not found") {
t.Errorf("期望返回 API 风格错误,实际 %s", w.Body.String())
}
})
t.Run("SPA fallback", func(t *testing.T) {
req := httptest.NewRequest("GET", "/providers", nil)
w := httptest.NewRecorder()
@@ -94,13 +74,12 @@ func TestSetupStaticFiles(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == 200 {
expected := "application/javascript"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
} else {
t.Log("文件不存在,跳过 MIME 类型验证")
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
expected := "application/javascript"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
})
@@ -109,15 +88,144 @@ func TestSetupStaticFiles(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == 200 {
expected := "text/css"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
} else {
t.Log("文件不存在,跳过 MIME 类型验证")
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
expected := "text/css"
if w.Header().Get("Content-Type") != expected {
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
}
})
t.Log("静态文件服务测试通过")
}
func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupStaticFilesWithFS(r, fstest.MapFS{
"icon.png": {Data: []byte("png")},
"index.html": {Data: []byte("<html>fallback</html>")},
})
req := httptest.NewRequest("GET", "/icon.png", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/png" {
t.Fatalf("期望 Content-Type image/png, 实际 %s", w.Header().Get("Content-Type"))
}
if w.Body.String() != "png" {
t.Fatalf("期望返回 PNG 内容,实际 %q", w.Body.String())
}
}
func TestWithProtocolAndStaticRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
var gotProtocol string
var gotPath string
r.Any("/openai/*path", withProtocol("openai", func(c *gin.Context) {
gotProtocol = c.Param("protocol")
gotPath = c.Param("path")
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
}))
r.Any("/anthropic/*path", withProtocol("anthropic", func(c *gin.Context) {
gotProtocol = c.Param("protocol")
gotPath = c.Param("path")
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
}))
setupStaticFilesWithFS(r, fstest.MapFS{
"index.html": {Data: []byte("<html>fallback</html>")},
"assets/test.js": {Data: []byte("console.log('test')")},
})
t.Run("OpenAI route enters proxy handler wrapper", func(t *testing.T) {
gotProtocol = ""
gotPath = ""
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("期望状态码 200, 实际 %d", w.Code)
}
if gotProtocol != "openai" {
t.Errorf("期望 protocol=openai, 实际 %s", gotProtocol)
}
if gotPath != "/v1/chat/completions" {
t.Errorf("期望 path=/v1/chat/completions, 实际 %s", gotPath)
}
})
t.Run("Anthropic route enters proxy handler wrapper", func(t *testing.T) {
gotProtocol = ""
gotPath = ""
req := httptest.NewRequest("POST", "/anthropic/v1/messages", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("期望状态码 200, 实际 %d", w.Code)
}
if gotProtocol != "anthropic" {
t.Errorf("期望 protocol=anthropic, 实际 %s", gotProtocol)
}
if gotPath != "/v1/messages" {
t.Errorf("期望 path=/v1/messages, 实际 %s", gotPath)
}
})
t.Run("Static assets are not hijacked", func(t *testing.T) {
gotProtocol = ""
gotPath = ""
req := httptest.NewRequest("GET", "/assets/test.js", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if gotProtocol != "" || gotPath != "" {
t.Errorf("静态资源不应进入代理包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
}
if w.Code != http.StatusOK {
t.Fatalf("期望静态资源返回 200, 实际 %d", w.Code)
}
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
}
})
t.Run("SPA path keeps fallback", func(t *testing.T) {
req := httptest.NewRequest("GET", "/providers", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("期望状态码 200, 实际 %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "text/html") {
t.Errorf("期望返回 HTML实际 %s", w.Header().Get("Content-Type"))
}
})
t.Run("Unknown proxy-like path does not return index html", func(t *testing.T) {
req := httptest.NewRequest("GET", "/openai/unknown", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("显式代理路由应进入代理包装器,实际状态码 %d", w.Code)
}
if gotProtocol != "openai" || gotPath != "/unknown" {
t.Errorf("期望 unknown 代理路径进入 openai 包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
}
})
}

231
backend/cmd/desktop/tray.go Normal file
View File

@@ -0,0 +1,231 @@
package main
import (
"fmt"
"runtime"
"sync"
"time"
"nex/embedfs"
"github.com/getlantern/systray"
"go.uber.org/zap"
)
const defaultTrayReadyTimeout = 5 * time.Second
type trayMenuItem interface {
Disable()
Clicked() <-chan struct{}
}
type trayController interface {
Run(onReady func(), onExit func())
Quit()
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) trayMenuItem
AddSeparator()
}
type realTrayController struct{}
func (realTrayController) Run(onReady func(), onExit func()) {
systray.Run(onReady, onExit)
}
func (realTrayController) Quit() {
systray.Quit()
}
func (realTrayController) SetIcon(icon []byte) {
systray.SetIcon(icon)
}
func (realTrayController) SetTooltip(tooltip string) {
systray.SetTooltip(tooltip)
}
func (realTrayController) AddMenuItem(title, tooltip string) trayMenuItem {
return realTrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
}
func (realTrayController) AddSeparator() {
systray.AddSeparator()
}
type realTrayMenuItem struct {
item *systray.MenuItem
}
func (m realTrayMenuItem) Disable() {
m.item.Disable()
}
func (m realTrayMenuItem) Clicked() <-chan struct{} {
return m.item.ClickedCh
}
type trayOptions struct {
controller trayController
readyTimeout time.Duration
iconLoader func() ([]byte, error)
openBrowser func(string) error
notify func(string, string)
logger *zap.Logger
fatalErrCh <-chan error
}
func setupSystray(port int, fatalErrCh <-chan error) error {
return runSystray(port, trayOptions{
controller: realTrayController{},
readyTimeout: defaultTrayReadyTimeout,
iconLoader: loadTrayIcon,
openBrowser: openBrowser,
notify: showError,
logger: dialogLogger(),
fatalErrCh: fatalErrCh,
})
}
func runSystray(port int, opts trayOptions) error {
if opts.controller == nil {
opts.controller = realTrayController{}
}
if opts.readyTimeout <= 0 {
opts.readyTimeout = defaultTrayReadyTimeout
}
if opts.iconLoader == nil {
opts.iconLoader = loadTrayIcon
}
if opts.openBrowser == nil {
opts.openBrowser = openBrowser
}
if opts.notify == nil {
opts.notify = showError
}
if opts.logger == nil {
opts.logger = dialogLogger()
}
readyCh := make(chan struct{})
doneCh := make(chan struct{})
errCh := make(chan error, 1)
var readyOnce sync.Once
var errOnce sync.Once
signalReady := func() {
readyOnce.Do(func() { close(readyCh) })
}
signalError := func(err error) {
errOnce.Do(func() { errCh <- err })
}
go monitorTrayStartup(port, opts, readyCh, doneCh, signalError)
opts.controller.Run(func() {
handleTrayReady(port, opts, signalReady, signalError)
}, nil)
close(doneCh)
select {
case err := <-errCh:
return err
default:
return nil
}
}
func monitorTrayStartup(port int, opts trayOptions, readyCh <-chan struct{}, doneCh <-chan struct{}, signalError func(error)) {
timer := time.NewTimer(opts.readyTimeout)
defer timer.Stop()
ready := false
for {
select {
case <-readyCh:
ready = true
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
openDesktopBrowser(port, opts)
readyCh = nil
case <-timer.C:
if !ready {
signalError(newStartupError(phaseTray, "托盘初始化超时", fmt.Errorf("托盘未在 %s 内 ready", opts.readyTimeout)))
opts.controller.Quit()
}
case err := <-opts.fatalErrCh:
if err != nil {
signalError(newStartupError(phaseServer, startupServerErrorMessage(), err))
opts.controller.Quit()
}
case <-doneCh:
return
}
}
}
func handleTrayReady(port int, opts trayOptions, signalReady func(), signalError func(error)) {
defer func() {
if recovered := recover(); recovered != nil {
err := fmt.Errorf("托盘初始化 panic: %v", recovered)
signalError(newStartupError(phaseTray, "托盘菜单初始化失败", err))
opts.controller.Quit()
}
}()
icon, err := opts.iconLoader()
if err != nil {
signalError(newStartupError(phaseTray, "托盘图标资源无法加载", err))
opts.controller.Quit()
return
}
opts.controller.SetIcon(icon)
opts.controller.SetTooltip(appTooltip)
mOpen := opts.controller.AddMenuItem("打开管理界面", "在浏览器中打开")
opts.controller.AddSeparator()
mStatus := opts.controller.AddMenuItem("状态: 运行中", "")
mStatus.Disable()
mPort := opts.controller.AddMenuItem(desktopPortMenuTitle(port), "")
mPort.Disable()
opts.controller.AddSeparator()
mQuit := opts.controller.AddMenuItem("退出", "停止服务并退出")
go func() {
for {
select {
case <-mOpen.Clicked():
if err := opts.openBrowser(desktopURL(port)); err != nil {
opts.logger.Warn("打开浏览器失败", zap.Error(err))
}
case <-mQuit.Clicked():
doShutdown()
opts.controller.Quit()
return
}
}
}()
signalReady()
}
func openDesktopBrowser(port int, opts trayOptions) {
url := desktopURL(port)
if err := opts.openBrowser(url); err != nil {
opts.logger.Warn("无法打开浏览器", zap.Error(err))
opts.notify(appName, fmt.Sprintf("无法自动打开浏览器,请手动访问 %s", url))
}
}
func loadTrayIcon() ([]byte, error) {
if runtime.GOOS == "windows" {
return embedfs.Assets.ReadFile("assets/icon.ico")
}
return embedfs.Assets.ReadFile("assets/icon.png")
}

View File

@@ -0,0 +1,169 @@
package main
import (
"errors"
"sync"
"testing"
"time"
"go.uber.org/zap"
)
type fakeTrayController struct {
run func(onReady func(), onExit func())
quitCh chan struct{}
quitOnce sync.Once
icon []byte
tooltip string
menuItems []*fakeTrayMenuItem
}
func newFakeTrayController() *fakeTrayController {
return &fakeTrayController{quitCh: make(chan struct{})}
}
func (c *fakeTrayController) Run(onReady func(), onExit func()) {
if c.run != nil {
c.run(onReady, onExit)
return
}
onReady()
<-c.quitCh
if onExit != nil {
onExit()
}
}
func (c *fakeTrayController) Quit() {
c.quitOnce.Do(func() { close(c.quitCh) })
}
func (c *fakeTrayController) SetIcon(icon []byte) {
c.icon = append([]byte(nil), icon...)
}
func (c *fakeTrayController) SetTooltip(tooltip string) {
c.tooltip = tooltip
}
func (c *fakeTrayController) AddMenuItem(title, tooltip string) trayMenuItem {
item := &fakeTrayMenuItem{clicked: make(chan struct{}), title: title, tooltip: tooltip}
c.menuItems = append(c.menuItems, item)
return item
}
func (c *fakeTrayController) AddSeparator() {}
type fakeTrayMenuItem struct {
clicked chan struct{}
title string
tooltip string
disabled bool
}
func (m *fakeTrayMenuItem) Disable() {
m.disabled = true
}
func (m *fakeTrayMenuItem) Clicked() <-chan struct{} {
return m.clicked
}
func TestRunSystrayReadyOpensBrowser(t *testing.T) {
controller := newFakeTrayController()
opened := make(chan string, 1)
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(url string) error {
opened <- url
controller.Quit()
return nil
},
notify: func(string, string) {},
logger: zap.NewNop(),
})
if err != nil {
t.Fatalf("托盘 ready 成功不应返回错误: %v", err)
}
if got := <-opened; got != "http://localhost:19826" {
t.Fatalf("浏览器 URL = %s", got)
}
if string(controller.icon) != "icon" {
t.Fatalf("应设置托盘图标")
}
if controller.tooltip != appTooltip {
t.Fatalf("tooltip = %q, want %q", controller.tooltip, appTooltip)
}
}
func TestRunSystrayReadyTimeoutReturnsTrayStartupError(t *testing.T) {
controller := newFakeTrayController()
controller.run = func(_ func(), _ func()) {
<-controller.quitCh
}
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: 10 * time.Millisecond,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(string) error { return nil },
notify: func(string, string) {},
logger: zap.NewNop(),
})
if err == nil {
t.Fatal("托盘 ready timeout 应返回错误")
}
var startupErr *startupError
if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" {
t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err)
}
}
func TestRunSystrayIconLoadFailureReturnsTrayStartupError(t *testing.T) {
controller := newFakeTrayController()
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return nil, errors.New("missing icon") },
openBrowser: func(string) error { return nil },
notify: func(string, string) {},
logger: zap.NewNop(),
})
if err == nil {
t.Fatal("托盘图标加载失败应返回错误")
}
var startupErr *startupError
if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" {
t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err)
}
}
func TestRunSystrayBrowserOpenFailureIsNonFatal(t *testing.T) {
controller := newFakeTrayController()
notified := make(chan string, 1)
err := runSystray(19826, trayOptions{
controller: controller,
readyTimeout: time.Second,
iconLoader: func() ([]byte, error) { return []byte("icon"), nil },
openBrowser: func(string) error { return errors.New("no browser") },
notify: func(_, message string) {
notified <- message
controller.Quit()
},
logger: zap.NewNop(),
})
if err != nil {
t.Fatalf("浏览器打开失败不应成为 fatal: %v", err)
}
if got := <-notified; got == "" {
t.Fatal("浏览器打开失败应提示用户")
}
}

View File

@@ -22,13 +22,14 @@ import (
"nex/backend/internal/provider"
"nex/backend/internal/repository"
"nex/backend/internal/service"
"nex/backend/pkg/buildinfo"
pkgLogger "nex/backend/pkg/logger"
)
func main() {
minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadConfig()
cfg, cfgMeta, err := config.LoadServerConfigWithMetadata()
if err != nil {
minimalLogger.Fatal("加载配置失败", zap.Error(err))
}
@@ -92,6 +93,8 @@ func main() {
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
settingsHandler := handler.NewSettingsHandler(cfg, "server", false, cfgMeta.ConfigPath)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -101,7 +104,7 @@ func main() {
r.Use(middleware.Logging(zapLogger))
r.Use(middleware.CORS())
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
@@ -111,7 +114,11 @@ func main() {
}
go func() {
zapLogger.Info("AI Gateway 启动", zap.String("addr", srv.Addr))
zapLogger.Info("AI Gateway 启动",
zap.String("addr", srv.Addr),
zap.String("version", buildinfo.Version()),
zap.String("commit", buildinfo.Commit()),
zap.String("build_time", buildinfo.BuildTime()))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
zapLogger.Fatal("服务器启动失败", zap.Error(err))
}
@@ -135,8 +142,9 @@ func main() {
zapLogger.Info("服务器已关闭")
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
r.GET("/api/version", versionHandler.GetVersion)
providers := r.Group("/api/providers")
{
@@ -162,6 +170,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
stats.GET("/aggregate", statsHandler.AggregateStats)
}
settings := r.Group("/api/settings")
{
settings.GET("/startup", settingsHandler.GetStartupSettings)
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
}
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})

View File

@@ -0,0 +1,37 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"nex/backend/internal/handler"
"github.com/gin-gonic/gin"
)
func TestSetupRoutes_Version(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "server", false, ""))
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
var result map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
for _, key := range []string{"version", "commit", "build_time"} {
if result[key] == "" {
t.Fatalf("响应缺少 %s 字段: %#v", key, result)
}
}
}

View File

@@ -1,7 +1,6 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -37,7 +36,7 @@ type DatabaseConfig struct {
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,omitempty,min=1,max=65535"`
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
Password string `yaml:"password" mapstructure:"password"`
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
@@ -225,82 +224,156 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
// 尝试读取配置文件,如果不存在则忽略
if err := v.ReadInConfig(); err != nil {
if !os.IsNotExist(err) {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
// 配置文件不存在,创建默认配置文件
writeErr := v.SafeWriteConfigAs(configPath)
if writeErr == nil {
if os.IsNotExist(err) {
return nil
}
var alreadyExistsErr viper.ConfigFileAlreadyExistsError
if errors.As(writeErr, &alreadyExistsErr) {
return nil
}
return appErrors.Wrap(appErrors.ErrInternal, writeErr)
return appErrors.Wrap(appErrors.ErrInternal, err)
}
return nil
}
// LoadConfig loads config from YAML file, creates default if not exists
func LoadConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
}
return LoadConfigFromPath(configPath)
type ConfigMetadata struct {
ConfigPath string
}
// LoadConfigFromPath 从指定路径加载配置
func LoadConfigFromPath(configPath string) (*Config, error) {
// 1. 创建 Viper 实例
type loadOptions struct {
configPathOverride string
useCLI bool
useEnv bool
useConfigFlag bool
}
// resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
configPath := opts.configPathOverride
if !opts.useCLI && !opts.useConfigFlag {
return configPath, nil
}
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
if opts.useConfigFlag {
flagSet.String("config", opts.configPathOverride, "配置文件路径")
}
if opts.useCLI {
setupFlags(v, flagSet)
}
if err := flagSet.Parse(os.Args[1:]); err != nil {
return "", appErrors.Wrap(appErrors.ErrInvalidRequest, err)
}
if opts.useConfigFlag {
if f, err := flagSet.GetString("config"); err == nil && f != "" {
configPath = f
}
}
return configPath, nil
}
func loadConfig(opts loadOptions) (*Config, error) {
cfg, _, err := loadConfigWithMetadata(opts)
return cfg, err
}
func loadConfigWithMetadata(opts loadOptions) (*Config, ConfigMetadata, error) {
v := viper.New()
// 2. 定义 CLI 参数
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
flagSet.String("config", configPath, "配置文件路径")
setupFlags(v, flagSet)
// 3. 解析 CLI 参数(忽略错误,因为可能没有参数)
if err := flagSet.Parse(os.Args[1:]); err != nil {
return nil, appErrors.Wrap(appErrors.ErrInvalidRequest, err)
}
// 4. 获取配置文件路径(可能被 --config 参数覆盖)
if configPathFlag, err := flagSet.GetString("config"); err == nil && configPathFlag != "" {
configPath = configPathFlag
}
// 5. 设置默认值
setupDefaults(v)
// 6. 绑定环境变量
setupEnv(v)
// 7. 读取配置文件
if err := setupConfigFile(v, configPath); err != nil {
return nil, err
configPath, err := resolveConfigPath(v, opts)
if err != nil {
return nil, ConfigMetadata{}, err
}
if opts.useEnv {
setupEnv(v)
}
if err := setupConfigFile(v, configPath); err != nil {
return nil, ConfigMetadata{}, err
}
// 8. 反序列化到结构体
cfg := &Config{}
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
))); err != nil {
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
// 9. 验证配置
if err := cfg.Validate(); err != nil {
return nil, err
return nil, ConfigMetadata{}, err
}
return cfg, nil
return cfg, ConfigMetadata{ConfigPath: configPath}, nil
}
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
func LoadServerConfig() (*Config, error) {
cfg, _, err := LoadServerConfigWithMetadata()
return cfg, err
}
func LoadServerConfigWithMetadata() (*Config, ConfigMetadata, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: true,
useEnv: true,
useConfigFlag: true,
})
}
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
func LoadDesktopConfig() (*Config, error) {
cfg, _, err := LoadDesktopConfigWithMetadata()
return cfg, err
}
func LoadDesktopConfigWithMetadata() (*Config, ConfigMetadata, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
}
return loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
}
// LoadConfig loads config from YAML file.
// 向后兼容,等同于 LoadServerConfig。
func LoadConfig() (*Config, error) {
return LoadServerConfig()
}
// LoadConfigFromPath 从指定路径加载配置。
// 保留向后兼容,沿用 server 语义(支持 CLI、env 和 --config 覆盖)。
func LoadConfigFromPath(configPath string) (*Config, error) {
return loadConfig(loadOptions{
configPathOverride: configPath,
useCLI: true,
useEnv: true,
useConfigFlag: true,
})
}
// LoadDesktopConfigAtPath 从指定路径以 desktop 语义加载配置(仅配置文件和默认值),用于测试场景。
func LoadDesktopConfigAtPath(configPath string) (*Config, error) {
return loadConfig(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
}
// SaveConfig saves config to YAML file
@@ -309,13 +382,15 @@ func SaveConfig(cfg *Config) error {
if err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
return SaveConfigToPath(cfg, configPath)
}
func SaveConfigToPath(cfg *Config, configPath string) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return appErrors.Wrap(appErrors.ErrInternal, err)

View File

@@ -0,0 +1,151 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestLoadDesktopConfigAtPath_WithMetadata(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := DefaultConfig()
cfg.Server.Port = 8888
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
loaded, meta, err := loadConfigWithMetadata(loadOptions{
configPathOverride: configPath,
useCLI: false,
useEnv: false,
useConfigFlag: false,
})
require.NoError(t, err)
assert.Equal(t, 8888, loaded.Server.Port)
assert.Equal(t, configPath, meta.ConfigPath)
}
func TestSaveConfigToPath(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "sub", "config.yaml")
cfg := DefaultConfig()
cfg.Server.Port = 7777
err := SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(data), "7777")
}
func TestSaveConfigToPath_InvalidDir(t *testing.T) {
cfg := DefaultConfig()
err := SaveConfigToPath(cfg, "/dev/null/impossible/config.yaml")
assert.Error(t, err)
}
func TestDurationConversion(t *testing.T) {
cfg := DefaultConfig()
dto := configToDTO(cfg)
parsed, err := time.ParseDuration(dto.Server.ReadTimeout)
require.NoError(t, err)
assert.Equal(t, cfg.Server.ReadTimeout, parsed)
parsed, err = time.ParseDuration(dto.Database.ConnMaxLifetime)
require.NoError(t, err)
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed)
}
func TestSaveConfigToPath_DurationFormat(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := DefaultConfig()
cfg.Server.ReadTimeout = 30 * time.Second
cfg.Server.WriteTimeout = 1 * time.Minute
cfg.Database.ConnMaxLifetime = 1 * time.Hour
err := SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, "conn_max_lifetime: 1h0m0s")
assert.Contains(t, content, "read_timeout: 30s")
assert.Contains(t, content, "write_timeout: 1m0s")
}
func TestSaveAndReload_DurationRoundTrip(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
yamlContent := `
server:
port: 9826
read_timeout: 30s
write_timeout: 1m
database:
driver: sqlite
path: ` + filepath.Join(dir, "test.db") + `
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 30m
log:
level: info
path: ` + filepath.Join(dir, "log") + `
max_size: 100
max_backups: 10
max_age: 30
compress: true
`
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0o600))
cfg, err := LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 30*time.Minute, cfg.Database.ConnMaxLifetime)
err = SaveConfigToPath(cfg, configPath)
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(data), "conn_max_lifetime: 30m0s")
}
func configToDTO(c *Config) struct {
Server struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
Database struct {
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
} {
var result struct {
Server struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
Database struct {
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
}
result.Server.Port = c.Server.Port
result.Server.ReadTimeout = c.Server.ReadTimeout.String()
result.Server.WriteTimeout = c.Server.WriteTimeout.String()
result.Database.ConnMaxLifetime = c.Database.ConnMaxLifetime.String()
return result
}

View File

@@ -49,6 +49,28 @@ func TestAdapter_DetectInterfaceType(t *testing.T) {
}
}
func TestAdapter_APIReferenceNativePaths(t *testing.T) {
a := NewAdapter()
// docs/api_reference/anthropic defines messages and models under /v1.
tests := []struct {
path string
expected conversion.InterfaceType
}{
{"/v1/messages", conversion.InterfaceTypeChat},
{"/v1/models", conversion.InterfaceTypeModels},
{"/v1/models/claude-sonnet-4-5", conversion.InterfaceTypeModelInfo},
{"/messages", conversion.InterfaceTypePassthrough},
{"/models", conversion.InterfaceTypePassthrough},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.expected, a.DetectInterfaceType(tt.path))
})
}
}
func TestAdapter_BuildUrl(t *testing.T) {
a := NewAdapter()

View File

@@ -50,16 +50,24 @@ func (e *StreamEncoder) encodeMessageStart(event canonical.CanonicalStreamEvent)
}
if event.Message != nil {
msg := map[string]any{
"id": event.Message.ID,
"model": event.Message.Model,
"role": "assistant",
"id": event.Message.ID,
"type": "message",
"role": "assistant",
"content": []any{},
"model": event.Message.Model,
"stop_reason": nil,
"stop_sequence": nil,
}
if event.Message.Usage != nil {
usage := map[string]any{
msg["usage"] = map[string]any{
"input_tokens": event.Message.Usage.InputTokens,
"output_tokens": event.Message.Usage.OutputTokens,
}
msg["usage"] = usage
} else {
msg["usage"] = map[string]any{
"input_tokens": 0,
"output_tokens": 0,
}
}
payload["message"] = msg
}
@@ -147,6 +155,10 @@ func (e *StreamEncoder) encodeMessageDelta(event canonical.CanonicalStreamEvent)
payload["usage"] = map[string]any{
"output_tokens": event.Usage.OutputTokens,
}
} else {
payload["usage"] = map[string]any{
"output_tokens": 0,
}
}
return e.marshalEvent("message_delta", payload)
}

View File

@@ -21,8 +21,55 @@ func TestStreamEncoder_MessageStart(t *testing.T) {
s := string(chunks[0])
assert.True(t, strings.HasPrefix(s, "event: message_start\n"))
assert.Contains(t, s, "data: ")
assert.Contains(t, s, "msg_1")
assert.Contains(t, s, "claude-3")
var payload map[string]any
lines := strings.Split(s, "\n")
for _, l := range lines {
if strings.HasPrefix(l, "data: ") {
require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &payload))
break
}
}
msg, ok := payload["message"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "msg_1", msg["id"])
assert.Equal(t, "message", msg["type"])
assert.Equal(t, "assistant", msg["role"])
assert.Equal(t, []any{}, msg["content"])
assert.Equal(t, "claude-3", msg["model"])
assert.Nil(t, msg["stop_reason"])
assert.Nil(t, msg["stop_sequence"])
usage, okU := msg["usage"].(map[string]any)
require.True(t, okU)
assert.Equal(t, float64(0), usage["input_tokens"])
assert.Equal(t, float64(0), usage["output_tokens"])
}
func TestStreamEncoder_MessageStart_WithUsage(t *testing.T) {
e := NewStreamEncoder()
event := canonical.NewMessageStartEventWithUsage("msg_2", "gpt-4", &canonical.CanonicalUsage{InputTokens: 100, OutputTokens: 50})
chunks := e.EncodeEvent(event)
require.Len(t, chunks, 1)
s := string(chunks[0])
var payload map[string]any
lines := strings.Split(s, "\n")
for _, l := range lines {
if strings.HasPrefix(l, "data: ") {
require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &payload))
break
}
}
msg, ok := payload["message"].(map[string]any)
require.True(t, ok)
usage, okU := msg["usage"].(map[string]any)
require.True(t, okU)
assert.Equal(t, float64(100), usage["input_tokens"])
assert.Equal(t, float64(50), usage["output_tokens"])
}
func TestStreamEncoder_ContentBlockDelta(t *testing.T) {
@@ -179,6 +226,10 @@ func TestStreamEncoder_MessageDelta_WithStopReason(t *testing.T) {
delta, okd := payload["delta"].(map[string]any)
require.True(t, okd)
assert.Equal(t, "end_turn", delta["stop_reason"])
usage, oku := payload["usage"].(map[string]any)
require.True(t, oku, "message_delta SHALL always include usage")
assert.Equal(t, float64(0), usage["output_tokens"])
}
func TestStreamEncoder_MessageDelta_WithUsage(t *testing.T) {

View File

@@ -3,11 +3,13 @@ package conversion
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"nex/backend/internal/conversion/canonical"
pkglogger "nex/backend/pkg/logger"
)
@@ -71,7 +73,7 @@ func (e *ConversionEngine) IsPassthrough(clientProtocol, providerProtocol string
// ConvertHttpRequest 转换 HTTP 请求
func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtocol, providerProtocol string, provider *TargetProvider) (*HTTPRequestSpec, error) {
nativePath := spec.URL
nativePath, rawQuery := splitRequestPath(spec.URL)
if e.IsPassthrough(clientProtocol, providerProtocol) {
providerAdapter, err := e.registry.Get(providerProtocol)
@@ -96,8 +98,11 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc
}
}
providerURL := providerAdapter.BuildUrl(nativePath, interfaceType)
providerURL = appendRawQuery(providerURL, rawQuery)
return &HTTPRequestSpec{
URL: provider.BaseURL + nativePath,
URL: joinBaseURL(provider.BaseURL, providerURL),
Method: spec.Method,
Headers: providerAdapter.BuildHeaders(provider),
Body: rewrittenBody,
@@ -115,6 +120,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc
interfaceType := clientAdapter.DetectInterfaceType(nativePath)
providerURL := providerAdapter.BuildUrl(nativePath, interfaceType)
providerURL = appendRawQuery(providerURL, rawQuery)
providerHeaders := providerAdapter.BuildHeaders(provider)
providerBody, err := e.convertBody(interfaceType, clientAdapter, providerAdapter, provider, spec.Body)
if err != nil {
@@ -122,7 +128,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc
}
return &HTTPRequestSpec{
URL: provider.BaseURL + providerURL,
URL: joinBaseURL(provider.BaseURL, providerURL),
Method: spec.Method,
Headers: providerHeaders,
Body: providerBody,
@@ -198,7 +204,7 @@ func (e *ConversionEngine) CreateStreamConverter(clientProtocol, providerProtoco
ctx := ConversionContext{
ConversionID: uuid.New().String(),
InterfaceType: InterfaceTypeChat,
InterfaceType: interfaceType,
Timestamp: time.Now(),
}
@@ -268,7 +274,7 @@ func (e *ConversionEngine) convertResponseBody(interfaceType InterfaceType, clie
func (e *ConversionEngine) convertChatBody(clientAdapter, providerAdapter ProtocolAdapter, provider *TargetProvider, body []byte) ([]byte, error) {
canonicalReq, err := clientAdapter.DecodeRequest(body)
if err != nil {
return nil, NewConversionError(ErrorCodeJSONParseError, "解码请求失败").WithCause(err)
return nil, NewRequestJSONParseError("解码请求失败", err)
}
ctx := NewConversionContext(InterfaceTypeChat)
@@ -276,6 +282,9 @@ func (e *ConversionEngine) convertChatBody(clientAdapter, providerAdapter Protoc
if err != nil {
return nil, err
}
if containsUnsupportedMultimodal(canonicalReq) {
return nil, NewConversionError(ErrorCodeUnsupportedMultimodal, "跨协议暂不支持多模态内容")
}
encoded, err := providerAdapter.EncodeRequest(canonicalReq, provider)
if err != nil {
@@ -287,7 +296,7 @@ func (e *ConversionEngine) convertChatBody(clientAdapter, providerAdapter Protoc
func (e *ConversionEngine) convertChatResponseBody(clientAdapter, providerAdapter ProtocolAdapter, body []byte, modelOverride string) ([]byte, error) {
canonicalResp, err := providerAdapter.DecodeResponse(body)
if err != nil {
return nil, NewConversionError(ErrorCodeJSONParseError, "解码响应失败").WithCause(err)
return nil, NewResponseJSONParseError("解码响应失败", err)
}
if modelOverride != "" {
canonicalResp.Model = modelOverride
@@ -375,6 +384,7 @@ func (e *ConversionEngine) DetectInterfaceType(nativePath, clientProtocol string
if err != nil {
return InterfaceTypePassthrough, err
}
nativePath, _ = splitRequestPath(nativePath)
return adapter.DetectInterfaceType(nativePath), nil
}
@@ -398,3 +408,46 @@ func (e *ConversionEngine) EncodeError(err *ConversionError, clientProtocol stri
body, statusCode := adapter.EncodeError(err)
return body, statusCode, nil
}
func splitRequestPath(rawPath string) (string, string) {
path, query, found := strings.Cut(rawPath, "?")
if !found {
return rawPath, ""
}
return path, query
}
func appendRawQuery(path, rawQuery string) string {
if rawQuery == "" {
return path
}
if strings.Contains(path, "?") {
return path + "&" + rawQuery
}
return path + "?" + rawQuery
}
func joinBaseURL(baseURL, path string) string {
if baseURL == "" {
return path
}
if path == "" {
return baseURL
}
return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func containsUnsupportedMultimodal(req *canonical.CanonicalRequest) bool {
if req == nil {
return false
}
for _, msg := range req.Messages {
for _, block := range msg.Content {
switch block.Type {
case "image", "audio", "video", "file":
return true
}
}
}
return false
}

View File

@@ -0,0 +1,63 @@
package conversion_test
import (
"testing"
"nex/backend/internal/conversion"
"nex/backend/internal/conversion/anthropic"
"nex/backend/internal/conversion/openai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestConvertHttpRequest_SameProtocolUsesAdapterBuildURL(t *testing.T) {
tests := []struct {
name string
adapter conversion.ProtocolAdapter
clientProtocol string
providerProtocol string
baseURL string
nativePath string
expectedURL string
body []byte
}{
{
name: "openai base url includes version path",
adapter: openai.NewAdapter(),
clientProtocol: "openai",
providerProtocol: "openai",
baseURL: "http://example.com/v1",
nativePath: "/chat/completions",
expectedURL: "http://example.com/v1/chat/completions",
body: []byte(`{"model":"gpt-4","messages":[]}`),
},
{
name: "anthropic native path keeps v1",
adapter: anthropic.NewAdapter(),
clientProtocol: "anthropic",
providerProtocol: "anthropic",
baseURL: "http://example.com",
nativePath: "/v1/messages",
expectedURL: "http://example.com/v1/messages",
body: []byte(`{"model":"claude","messages":[]}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registry := conversion.NewMemoryRegistry()
engine := conversion.NewConversionEngine(registry, zap.NewNop())
require.NoError(t, registry.Register(tt.adapter))
out, err := engine.ConvertHttpRequest(conversion.HTTPRequestSpec{
URL: tt.nativePath,
Method: "POST",
Body: tt.body,
}, tt.clientProtocol, tt.providerProtocol, conversion.NewTargetProvider(tt.baseURL, "key", "upstream-model"))
require.NoError(t, err)
assert.Equal(t, tt.expectedURL, out.URL)
})
}
}

View File

@@ -2,6 +2,7 @@ package conversion
import (
"encoding/json"
"strings"
"testing"
"nex/backend/internal/conversion/canonical"
@@ -287,19 +288,33 @@ func TestDetectInterfaceType_NonExistentProtocol(t *testing.T) {
func TestConvertHttpRequest_Passthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
openaiAdapter := &buildURLMockAdapter{
mockProtocolAdapter: newMockAdapter("openai", true),
buildURLFn: func(nativePath string, interfaceType InterfaceType) string {
if interfaceType == InterfaceTypeChat {
return "/chat/completions"
}
return nativePath
},
}
openaiAdapter.ifaceType = InterfaceTypeChat
openaiAdapter.supportsIface[InterfaceTypeChat] = true
openaiAdapter.rewriteReqFn = func(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error) {
return []byte(`{"model":"` + newModel + `","messages":[{"role":"user","content":"hi"}]}`), nil
}
_ = engine.RegisterAdapter(openaiAdapter)
provider := NewTargetProvider("https://api.openai.com/v1", "sk-test", "gpt-4")
spec := HTTPRequestSpec{
URL: "/chat/completions",
URL: "/v1/chat/completions",
Method: "POST",
Body: []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`),
Body: []byte(`{"model":"openai/gpt-4","messages":[{"role":"user","content":"hi"}]}`),
}
result, err := engine.ConvertHttpRequest(spec, "openai", "openai", provider)
require.NoError(t, err)
assert.Equal(t, "https://api.openai.com/v1/chat/completions", result.URL)
assert.Equal(t, spec.Body, result.Body)
assert.JSONEq(t, `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`, string(result.Body))
}
func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
@@ -334,6 +349,77 @@ func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
assert.NotNil(t, result.Body)
}
func TestConvertHttpRequest_UsesProviderAdapterBuildURL(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, zap.NewNop())
openaiAdapter := &buildURLMockAdapter{
mockProtocolAdapter: newMockAdapter("openai", true),
buildURLFn: func(nativePath string, interfaceType InterfaceType) string {
if interfaceType == InterfaceTypeChat {
return "/chat/completions"
}
return nativePath
},
}
openaiAdapter.ifaceType = InterfaceTypeChat
openaiAdapter.supportsIface[InterfaceTypeChat] = true
openaiAdapter.rewriteReqFn = func(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error) {
return []byte(`{"model":"` + newModel + `"}`), nil
}
require.NoError(t, registry.Register(openaiAdapter))
anthropicAdapter := &buildURLMockAdapter{
mockProtocolAdapter: newMockAdapter("anthropic", false),
buildURLFn: func(nativePath string, interfaceType InterfaceType) string {
if interfaceType == InterfaceTypeChat {
return "/v1/messages"
}
return nativePath
},
}
anthropicAdapter.ifaceType = InterfaceTypeChat
anthropicAdapter.supportsIface[InterfaceTypeChat] = true
require.NoError(t, registry.Register(anthropicAdapter))
t.Run("OpenAI to Anthropic", func(t *testing.T) {
provider := NewTargetProvider("https://api.anthropic.com", "key", "claude-3")
spec := HTTPRequestSpec{
URL: "/v1/chat/completions",
Method: "POST",
Body: []byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"max_tokens":16}`),
}
result, err := engine.ConvertHttpRequest(spec, "openai", "anthropic", provider)
require.NoError(t, err)
assert.Equal(t, "https://api.anthropic.com/v1/messages", result.URL)
})
t.Run("Anthropic to OpenAI", func(t *testing.T) {
provider := NewTargetProvider("https://api.openai.com/v1", "key", "gpt-4")
spec := HTTPRequestSpec{
URL: "/v1/messages",
Method: "POST",
Body: []byte(`{"model":"p1/claude-3","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`),
}
result, err := engine.ConvertHttpRequest(spec, "anthropic", "openai", provider)
require.NoError(t, err)
assert.Equal(t, "https://api.openai.com/v1/chat/completions", result.URL)
})
}
type buildURLMockAdapter struct {
*mockProtocolAdapter
buildURLFn func(string, InterfaceType) string
}
func (m *buildURLMockAdapter) BuildUrl(nativePath string, interfaceType InterfaceType) string {
if m.buildURLFn != nil {
return m.buildURLFn(nativePath, interfaceType)
}
return m.mockProtocolAdapter.BuildUrl(nativePath, interfaceType)
}
func TestConvertHttpResponse_Passthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, zap.NewNop())
@@ -498,12 +584,13 @@ func TestCreateStreamConverter_ModelOverride_SmartPassthrough(t *testing.T) {
_, ok := converter.(*SmartPassthroughStreamConverter)
assert.True(t, ok)
// 验证 chunk 改写
chunks := converter.ProcessChunk([]byte(`{"model":"gpt-4","choices":[]}`))
// 验证 SSE frame 中的 data JSON 被改写
chunks := converter.ProcessChunk([]byte(`data: {"model":"gpt-4","choices":[]}` + "\n\n"))
require.Len(t, chunks, 1)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(chunks[0], &resp))
payload := strings.TrimPrefix(strings.TrimSpace(string(chunks[0])), "data: ")
require.NoError(t, json.Unmarshal([]byte(payload), &resp))
assert.Equal(t, "openai/gpt-4", resp["model"])
}

View File

@@ -17,6 +17,13 @@ const (
ErrorCodeProtocolConstraint ErrorCode = "PROTOCOL_CONSTRAINT_VIOLATION"
ErrorCodeEncodingFailure ErrorCode = "ENCODING_FAILURE"
ErrorCodeInterfaceNotSupported ErrorCode = "INTERFACE_NOT_SUPPORTED"
ErrorCodeUnsupportedMultimodal ErrorCode = "UNSUPPORTED_MULTIMODAL"
)
const (
ErrorDetailPhase = "phase"
ErrorPhaseRequest = "request"
ErrorPhaseResponse = "response"
)
// ConversionError 协议转换错误
@@ -39,6 +46,20 @@ func NewConversionError(code ErrorCode, message string) *ConversionError {
}
}
// NewRequestJSONParseError 创建请求 JSON 解析错误。
func NewRequestJSONParseError(message string, cause error) *ConversionError {
return NewConversionError(ErrorCodeJSONParseError, message).
WithDetail(ErrorDetailPhase, ErrorPhaseRequest).
WithCause(cause)
}
// NewResponseJSONParseError 创建响应 JSON 解析错误。
func NewResponseJSONParseError(message string, cause error) *ConversionError {
return NewConversionError(ErrorCodeJSONParseError, message).
WithDetail(ErrorDetailPhase, ErrorPhaseResponse).
WithCause(cause)
}
// WithClientProtocol 设置客户端协议
func (e *ConversionError) WithClientProtocol(protocol string) *ConversionError {
e.ClientProtocol = protocol

View File

@@ -29,27 +29,27 @@ func (a *Adapter) SupportsPassthrough() bool { return true }
// DetectInterfaceType 根据路径检测接口类型
func (a *Adapter) DetectInterfaceType(nativePath string) conversion.InterfaceType {
switch {
case nativePath == "/chat/completions":
case nativePath == "/v1/chat/completions":
return conversion.InterfaceTypeChat
case nativePath == "/models":
case nativePath == "/v1/models":
return conversion.InterfaceTypeModels
case isModelInfoPath(nativePath):
return conversion.InterfaceTypeModelInfo
case nativePath == "/embeddings":
case nativePath == "/v1/embeddings":
return conversion.InterfaceTypeEmbeddings
case nativePath == "/rerank":
case nativePath == "/v1/rerank":
return conversion.InterfaceTypeRerank
default:
return conversion.InterfaceTypePassthrough
}
}
// isModelInfoPath 判断是否为模型详情路径(/models/{id},允许 id 含 /
// isModelInfoPath 判断是否为模型详情路径(/v1/models/{id},允许 id 含 /
func isModelInfoPath(path string) bool {
if !strings.HasPrefix(path, "/models/") {
if !strings.HasPrefix(path, "/v1/models/") {
return false
}
suffix := path[len("/models/"):]
suffix := path[len("/v1/models/"):]
return suffix != ""
}
@@ -60,6 +60,11 @@ func (a *Adapter) BuildUrl(nativePath string, interfaceType conversion.Interface
return "/chat/completions"
case conversion.InterfaceTypeModels:
return "/models"
case conversion.InterfaceTypeModelInfo:
if modelID, err := a.ExtractUnifiedModelID(nativePath); err == nil {
return "/models/" + modelID
}
return nativePath
case conversion.InterfaceTypeEmbeddings:
return "/embeddings"
case conversion.InterfaceTypeRerank:
@@ -221,12 +226,12 @@ func (a *Adapter) EncodeRerankResponse(resp *canonical.CanonicalRerankResponse)
return encodeRerankResponse(resp)
}
// ExtractUnifiedModelID 从路径中提取统一模型 ID/models/{provider_id}/{model_name}
// ExtractUnifiedModelID 从路径中提取统一模型 ID/v1/models/{provider_id}/{model_name}
func (a *Adapter) ExtractUnifiedModelID(nativePath string) (string, error) {
if !strings.HasPrefix(nativePath, "/models/") {
if !strings.HasPrefix(nativePath, "/v1/models/") {
return "", fmt.Errorf("不是模型详情路径: %s", nativePath)
}
suffix := nativePath[len("/models/"):]
suffix := nativePath[len("/v1/models/"):]
if suffix == "" {
return "", fmt.Errorf("路径缺少模型 ID")
}

View File

@@ -28,11 +28,11 @@ func TestAdapter_DetectInterfaceType(t *testing.T) {
path string
expected conversion.InterfaceType
}{
{"聊天补全", "/chat/completions", conversion.InterfaceTypeChat},
{"模型列表", "/models", conversion.InterfaceTypeModels},
{"模型详情", "/models/gpt-4", conversion.InterfaceTypeModelInfo},
{"嵌入接口", "/embeddings", conversion.InterfaceTypeEmbeddings},
{"重排序接口", "/rerank", conversion.InterfaceTypeRerank},
{"聊天补全", "/v1/chat/completions", conversion.InterfaceTypeChat},
{"模型列表", "/v1/models", conversion.InterfaceTypeModels},
{"模型详情", "/v1/models/openai/gpt-4", conversion.InterfaceTypeModelInfo},
{"嵌入接口", "/v1/embeddings", conversion.InterfaceTypeEmbeddings},
{"重排序接口", "/v1/rerank", conversion.InterfaceTypeRerank},
{"未知路径", "/unknown", conversion.InterfaceTypePassthrough},
}
@@ -44,6 +44,27 @@ func TestAdapter_DetectInterfaceType(t *testing.T) {
}
}
func TestAdapter_OldPathsBecomePassthrough(t *testing.T) {
a := NewAdapter()
tests := []struct {
path string
expected conversion.InterfaceType
}{
{"/chat/completions", conversion.InterfaceTypePassthrough},
{"/models", conversion.InterfaceTypePassthrough},
{"/models/gpt-4.1", conversion.InterfaceTypePassthrough},
{"/embeddings", conversion.InterfaceTypePassthrough},
{"/rerank", conversion.InterfaceTypePassthrough},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.expected, a.DetectInterfaceType(tt.path))
})
}
}
func TestAdapter_BuildUrl(t *testing.T) {
a := NewAdapter()
@@ -53,10 +74,12 @@ func TestAdapter_BuildUrl(t *testing.T) {
interfaceType conversion.InterfaceType
expected string
}{
{"聊天", "/chat/completions", conversion.InterfaceTypeChat, "/chat/completions"},
{"模型", "/models", conversion.InterfaceTypeModels, "/models"},
{"嵌入", "/embeddings", conversion.InterfaceTypeEmbeddings, "/embeddings"},
{"重排序", "/rerank", conversion.InterfaceTypeRerank, "/rerank"},
{"聊天", "/v1/chat/completions", conversion.InterfaceTypeChat, "/chat/completions"},
{"模型", "/v1/models", conversion.InterfaceTypeModels, "/models"},
{"模型详情", "/v1/models/openai/gpt-4", conversion.InterfaceTypeModelInfo, "/models/openai/gpt-4"},
{"复杂模型详情", "/v1/models/azure/accounts/org/models/gpt-4", conversion.InterfaceTypeModelInfo, "/models/azure/accounts/org/models/gpt-4"},
{"嵌入", "/v1/embeddings", conversion.InterfaceTypeEmbeddings, "/embeddings"},
{"重排序", "/v1/rerank", conversion.InterfaceTypeRerank, "/rerank"},
{"默认透传", "/other", conversion.InterfaceTypePassthrough, "/other"},
}
@@ -118,12 +141,12 @@ func TestIsModelInfoPath(t *testing.T) {
path string
expected bool
}{
{"model_info", "/models/gpt-4", true},
{"model_info_with_dots", "/models/gpt-4.1-preview", true},
{"models_list", "/models", false},
{"nested_path", "/models/gpt-4/versions", true},
{"empty_suffix", "/models/", false},
{"unrelated", "/chat/completions", false},
{"model_info", "/v1/models/openai/gpt-4", true},
{"model_info_with_dots", "/v1/models/openai/gpt-4.1-preview", true},
{"models_list", "/v1/models", false},
{"nested_path", "/v1/models/azure/accounts/org-123/models/gpt-4", true},
{"empty_suffix", "/v1/models/", false},
{"unrelated", "/v1/chat/completions", false},
{"partial_prefix", "/model", false},
}
@@ -134,6 +157,27 @@ func TestIsModelInfoPath(t *testing.T) {
}
}
func TestAdapter_ExtractUnifiedModelID(t *testing.T) {
a := NewAdapter()
t.Run("标准路径", func(t *testing.T) {
modelID, err := a.ExtractUnifiedModelID("/v1/models/openai/gpt-4")
require.NoError(t, err)
assert.Equal(t, "openai/gpt-4", modelID)
})
t.Run("复杂路径", func(t *testing.T) {
modelID, err := a.ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")
require.NoError(t, err)
assert.Equal(t, "azure/accounts/org/models/gpt-4", modelID)
})
t.Run("非模型详情路径报错", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/v1/models")
require.Error(t, err)
})
}
func TestAdapter_EncodeError_InvalidInput(t *testing.T) {
a := NewAdapter()
convErr := conversion.NewConversionError(conversion.ErrorCodeInvalidInput, "参数无效")

View File

@@ -18,35 +18,35 @@ func TestExtractUnifiedModelID(t *testing.T) {
a := NewAdapter()
t.Run("standard_path", func(t *testing.T) {
id, err := a.ExtractUnifiedModelID("/models/openai/gpt-4")
id, err := a.ExtractUnifiedModelID("/v1/models/openai/gpt-4")
require.NoError(t, err)
assert.Equal(t, "openai/gpt-4", id)
})
t.Run("multi_segment_path", func(t *testing.T) {
id, err := a.ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")
id, err := a.ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")
require.NoError(t, err)
assert.Equal(t, "azure/accounts/org/models/gpt-4", id)
})
t.Run("single_segment", func(t *testing.T) {
id, err := a.ExtractUnifiedModelID("/models/gpt-4")
id, err := a.ExtractUnifiedModelID("/v1/models/gpt-4")
require.NoError(t, err)
assert.Equal(t, "gpt-4", id)
})
t.Run("non_model_path", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/chat/completions")
_, err := a.ExtractUnifiedModelID("/v1/chat/completions")
require.Error(t, err)
})
t.Run("empty_suffix", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/models/")
_, err := a.ExtractUnifiedModelID("/v1/models/")
require.Error(t, err)
})
t.Run("models_list_no_slash", func(t *testing.T) {
_, err := a.ExtractUnifiedModelID("/models")
_, err := a.ExtractUnifiedModelID("/v1/models")
require.Error(t, err)
})
@@ -344,12 +344,12 @@ func TestIsModelInfoPath_UnifiedModelID(t *testing.T) {
path string
expected bool
}{
{"simple_model_id", "/models/gpt-4", true},
{"unified_model_id_with_slash", "/models/openai/gpt-4", true},
{"models_list", "/models", false},
{"models_list_trailing_slash", "/models/", false},
{"chat_completions", "/chat/completions", false},
{"deeply_nested", "/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
{"simple_model_id", "/v1/models/gpt-4", true},
{"unified_model_id_with_slash", "/v1/models/openai/gpt-4", true},
{"models_list", "/v1/models", false},
{"models_list_trailing_slash", "/v1/models/", false},
{"chat_completions", "/v1/chat/completions", false},
{"deeply_nested", "/v1/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
}
for _, tt := range tests {

View File

@@ -1,6 +1,11 @@
package conversion
import "nex/backend/internal/conversion/canonical"
import (
"bytes"
"strings"
"nex/backend/internal/conversion/canonical"
)
// StreamDecoder 流式解码器接口
type StreamDecoder interface {
@@ -39,11 +44,12 @@ func (c *PassthroughStreamConverter) Flush() [][]byte {
}
// SmartPassthroughStreamConverter 同协议 Smart Passthrough 流式转换器
// 逐 chunk 改写 model 字段
// 按 SSE frame 改写 data JSON 中的 model 字段
type SmartPassthroughStreamConverter struct {
adapter ProtocolAdapter
modelOverride string
interfaceType InterfaceType
buffer []byte
}
// NewSmartPassthroughStreamConverter 创建 Smart Passthrough 流式转换器
@@ -55,24 +61,45 @@ func NewSmartPassthroughStreamConverter(adapter ProtocolAdapter, modelOverride s
}
}
// ProcessChunk 改写 chunk 中的 model 字段
// ProcessChunk 按 SSE frame 改写 data JSON 中的 model 字段
func (c *SmartPassthroughStreamConverter) ProcessChunk(rawChunk []byte) [][]byte {
if len(rawChunk) == 0 {
return nil
}
rewrittenChunk, err := c.adapter.RewriteResponseModelName(rawChunk, c.modelOverride, c.interfaceType)
if err != nil {
// 改写失败,返回原始 chunk
return [][]byte{rawChunk}
}
c.buffer = append(c.buffer, rawChunk...)
frames, rest := splitSSEFrames(c.buffer)
c.buffer = rest
return [][]byte{rewrittenChunk}
result := make([][]byte, 0, len(frames))
for _, frame := range frames {
result = append(result, c.rewriteFrame(frame))
}
return result
}
// Flush 无缓冲数据
func (c *SmartPassthroughStreamConverter) rewriteFrame(frame []byte) []byte {
payload, ok := sseFrameDataPayload(frame)
if !ok || strings.TrimSpace(payload) == "[DONE]" {
return frame
}
rewrittenPayload, err := c.adapter.RewriteResponseModelName([]byte(payload), c.modelOverride, c.interfaceType)
if err != nil {
return frame
}
return rebuildSSEFrameWithData(frame, string(rewrittenPayload))
}
// Flush 输出未形成完整 frame 的剩余数据
func (c *SmartPassthroughStreamConverter) Flush() [][]byte {
return nil
if len(c.buffer) == 0 {
return nil
}
frame := append([]byte(nil), c.buffer...)
c.buffer = nil
return [][]byte{c.rewriteFrame(frame)}
}
// CanonicalStreamConverter 跨协议规范流式转换器
@@ -153,3 +180,86 @@ func (c *CanonicalStreamConverter) applyModelOverride(event *canonical.Canonical
event.Message.Model = c.modelOverride
}
}
func splitSSEFrames(data []byte) ([][]byte, []byte) {
var frames [][]byte
for len(data) > 0 {
idx, sepLen := findSSEFrameSeparator(data)
if idx < 0 {
break
}
end := idx + sepLen
frames = append(frames, append([]byte(nil), data[:end]...))
data = data[end:]
}
return frames, data
}
func findSSEFrameSeparator(data []byte) (int, int) {
lf := bytes.Index(data, []byte("\n\n"))
crlf := bytes.Index(data, []byte("\r\n\r\n"))
switch {
case lf < 0 && crlf < 0:
return -1, 0
case lf < 0:
return crlf, 4
case crlf < 0:
return lf, 2
case crlf <= lf:
return crlf, 4
default:
return lf, 2
}
}
func sseFrameDataPayload(frame []byte) (string, bool) {
text := strings.TrimRight(string(frame), "\r\n")
lines := strings.Split(text, "\n")
var dataLines []string
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "data:") {
value := strings.TrimPrefix(line, "data:")
if strings.HasPrefix(value, " ") {
value = value[1:]
}
dataLines = append(dataLines, value)
}
}
if len(dataLines) == 0 {
return "", false
}
return strings.Join(dataLines, "\n"), true
}
func rebuildSSEFrameWithData(frame []byte, data string) []byte {
lineEnding, separator := sseLineEnding(frame)
text := strings.TrimRight(string(frame), "\r\n")
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines)+1)
dataWritten := false
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "data:") {
if !dataWritten {
for _, dataLine := range strings.Split(data, "\n") {
out = append(out, "data: "+dataLine)
}
dataWritten = true
}
continue
}
out = append(out, line)
}
if !dataWritten {
out = append(out, "data: "+data)
}
return []byte(strings.Join(out, lineEnding) + separator)
}
func sseLineEnding(frame []byte) (string, string) {
if bytes.Contains(frame, []byte("\r\n")) {
return "\r\n", "\r\n\r\n"
}
return "\n", "\n\n"
}

View File

@@ -1,10 +1,11 @@
package database
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
@@ -13,9 +14,12 @@ import (
"gorm.io/gorm"
"nex/backend/internal/config"
"nex/backend/migrations"
pkglogger "nex/backend/pkg/logger"
)
var ErrMigration = errors.New("数据库迁移失败")
func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
moduleLogger := pkglogger.WithModule(zapLogger, "database")
@@ -25,7 +29,7 @@ func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
}
if err := runMigrations(db, cfg.Driver, moduleLogger); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
return nil, fmt.Errorf("%w: %w", ErrMigration, err)
}
configurePool(db, cfg, moduleLogger)
@@ -77,29 +81,24 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
return err
}
gooseDialect := "sqlite3"
migrationsSubDir := "sqlite"
if driver == "mysql" {
gooseDialect = "mysql"
migrationsSubDir = "mysql"
}
migrationsDir := getMigrationsDir(driver)
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
dialect, fsys, err := migrations.ForDriver(driver)
if err != nil {
return err
}
if zapLogger != nil {
zapLogger.Info("执行数据库迁移",
zap.String("dialect", gooseDialect),
zap.String("dir", migrationsSubDir))
zap.String("dialect", string(dialect)),
zap.String("driver", driver))
}
if err := goose.SetDialect(gooseDialect); err != nil {
return err
provider, err := goose.NewProvider(dialect, sqlDB, fsys)
if err != nil {
return fmt.Errorf("创建迁移提供者失败: %w", err)
}
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err
if _, err := provider.Up(context.Background()); err != nil {
return fmt.Errorf("执行迁移失败: %w", err)
}
return nil
@@ -130,21 +129,6 @@ func configurePool(db *gorm.DB, cfg *config.DatabaseConfig, zapLogger *zap.Logge
}
}
func getMigrationsDir(driver string) string {
_, filename, _, ok := runtime.Caller(0)
if ok {
subDir := "sqlite"
if driver == "mysql" {
subDir = "mysql"
}
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", subDir)
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations"
}
func BuildDSN(cfg *config.DatabaseConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)

View File

@@ -1,10 +1,13 @@
package database
import (
"io/fs"
"os"
"path/filepath"
"testing"
"nex/backend/internal/config"
"nex/backend/migrations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
dsn := BuildDSN(cfg)
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
}
func TestInit_SQLite_AnyCWD(t *testing.T) {
dir := t.TempDir()
origDir, err := os.Getwd()
if err == nil {
defer func() {
if chdirErr := os.Chdir(origDir); chdirErr != nil {
t.Logf("无法恢复工作目录: %v", chdirErr)
}
}()
}
if chdirErr := os.Chdir(dir); chdirErr != nil {
t.Skipf("无法切换到临时目录: %v", chdirErr)
}
cfg := &config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := Init(cfg, zapLogger)
require.NoError(t, err)
require.NotNil(t, db)
defer Close(db)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NotNil(t, sqlDB)
}
func TestForDriverDialect_SQLite(t *testing.T) {
require.NoError(t, testMigrateWithDriver(t, "sqlite"))
}
func TestForDriverDialect_MySQL(t *testing.T) {
dialect, fsys, err := migrations.ForDriver("mysql")
require.NoError(t, err)
assert.Equal(t, "mysql", string(dialect))
entries, fsErr := fs.ReadDir(fsys, ".")
require.NoError(t, fsErr)
assert.NotEmpty(t, entries, "MySQL 迁移资源应至少包含一个文件")
}
func TestForDriverDialect_Invalid(t *testing.T) {
dir := t.TempDir()
cfg := &config.DatabaseConfig{
Driver: "postgres",
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
_, err := Init(cfg, zapLogger)
assert.Error(t, err, "非法 driver 应返回错误")
assert.Contains(t, err.Error(), "不支持的数据库驱动")
}
func testMigrateWithDriver(t *testing.T, driver string) error {
t.Helper()
dir := t.TempDir()
cfg := &config.DatabaseConfig{
Driver: driver,
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := Init(cfg, zapLogger)
if err != nil {
return err
}
Close(db)
return nil
}

View File

@@ -0,0 +1,71 @@
package database
import (
"io/fs"
"testing"
"nex/backend/migrations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEmbeddedMigrations_SQLiteResourcesPresent(t *testing.T) {
entries, err := fs.ReadDir(migrations.FS, "sqlite")
require.NoError(t, err)
var sqlFiles []string
for _, entry := range entries {
if !entry.IsDir() {
sqlFiles = append(sqlFiles, entry.Name())
}
}
assert.NotEmpty(t, sqlFiles, "SQLite 迁移资源应至少包含一个 .sql 文件")
}
func TestEmbeddedMigrations_MySQLResourcesPresent(t *testing.T) {
entries, err := fs.ReadDir(migrations.FS, "mysql")
require.NoError(t, err)
var sqlFiles []string
for _, entry := range entries {
if !entry.IsDir() {
sqlFiles = append(sqlFiles, entry.Name())
}
}
assert.NotEmpty(t, sqlFiles, "MySQL 迁移资源应至少包含一个 .sql 文件")
}
func TestEmbeddedMigrations_SQLiteSQLParsable(t *testing.T) {
subFS, err := fs.Sub(migrations.FS, "sqlite")
require.NoError(t, err)
entries, err := fs.ReadDir(subFS, ".")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := fs.ReadFile(subFS, entry.Name())
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
}
}
func TestEmbeddedMigrations_MySQLSQLParsable(t *testing.T) {
subFS, err := fs.Sub(migrations.FS, "mysql")
require.NoError(t, err)
entries, err := fs.ReadDir(subFS, ".")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := fs.ReadFile(subFS, entry.Name())
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
}
}

View File

@@ -20,7 +20,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
if id, ok := requestID.(string); ok {
requestIDStr = id
}
logger.Info("请求开始",
logger.Debug("请求开始",
pkglogger.Method(c.Request.Method),
pkglogger.Path(path),
pkglogger.Query(query),
@@ -33,7 +33,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
latency := time.Since(start)
statusCode := c.Writer.Status()
logger.Info("请求结束",
logger.Debug("请求结束",
pkglogger.StatusCode(statusCode),
pkglogger.Method(c.Request.Method),
pkglogger.Path(path),

View File

@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func init() {
@@ -65,6 +67,61 @@ func TestLogging(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func TestLogging_DoesNotLogLifecycleAtInfoLevel(t *testing.T) {
core, logs := observer.New(zapcore.InfoLevel)
logger := zap.New(core)
w := serveLoggingRequest(logger)
assert.Equal(t, 200, w.Code)
assert.Empty(t, logs.FilterMessage("请求开始").All())
assert.Empty(t, logs.FilterMessage("请求结束").All())
}
func TestLogging_LogsLifecycleAtDebugLevel(t *testing.T) {
core, logs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
w := serveLoggingRequest(logger)
assert.Equal(t, 200, w.Code)
startLogs := logs.FilterMessage("请求开始").All()
endLogs := logs.FilterMessage("请求结束").All()
if assert.Len(t, startLogs, 1) {
fields := startLogs[0].ContextMap()
assert.Equal(t, "GET", fields["method"])
assert.Equal(t, "/test", fields["path"])
assert.Equal(t, "key=value", fields["query"])
assert.Equal(t, "existing-id-123", fields["request_id"])
assert.NotEmpty(t, fields["client_ip"])
}
if assert.Len(t, endLogs, 1) {
fields := endLogs[0].ContextMap()
assert.Equal(t, int64(200), fields["status"])
assert.Equal(t, "GET", fields["method"])
assert.Equal(t, "/test", fields["path"])
assert.Equal(t, int64(2), fields["body_size"])
assert.Equal(t, "existing-id-123", fields["request_id"])
assert.Contains(t, fields, "latency")
}
}
func serveLoggingRequest(logger *zap.Logger) *httptest.ResponseRecorder {
r := gin.New()
r.Use(RequestID())
r.Use(Logging(logger))
r.GET("/test", func(c *gin.Context) {
c.String(200, "ok")
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test?key=value", nil)
req.Header.Set("X-Request-ID", "existing-id-123")
r.ServeHTTP(w, req)
return w
}
func TestRecovery_NoPanic(t *testing.T) {
logger := zap.NewNop()

View File

@@ -13,6 +13,7 @@ import (
"nex/backend/internal/domain"
"nex/backend/internal/provider"
"nex/backend/internal/service"
appErrors "nex/backend/pkg/errors"
"nex/backend/pkg/modelid"
"github.com/gin-gonic/gin"
@@ -48,7 +49,7 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
// 从 URL 提取 clientProtocol: /{protocol}/v1/...
clientProtocol := c.Param("protocol")
if clientProtocol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少协议前缀"})
h.writeProxyError(c, http.StatusBadRequest, "INVALID_REQUEST", "缺少协议前缀")
return
}
@@ -58,12 +59,13 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
path = "/" + path
}
nativePath := path
requestPath := appendRawQuery(nativePath, c.Request.URL.RawQuery)
// 获取 client adapter
registry := h.engine.GetRegistry()
clientAdapter, err := registry.Get(clientProtocol)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的协议: " + clientProtocol})
h.writeProxyError(c, http.StatusNotFound, "UNSUPPORTED_INTERFACE", "不支持的协议: "+clientProtocol)
return
}
@@ -80,7 +82,7 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
if ifaceType == conversion.InterfaceTypeModelInfo {
unifiedID, err := clientAdapter.ExtractUnifiedModelID(nativePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的模型 ID 格式"})
h.writeProxyError(c, http.StatusBadRequest, "INVALID_MODEL_ID", "无效的模型 ID 格式")
return
}
h.handleModelInfo(c, unifiedID, clientAdapter)
@@ -90,40 +92,50 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
// 读取请求体
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
h.writeProxyError(c, http.StatusBadRequest, "INVALID_REQUEST", "读取请求体失败")
return
}
// 解析统一模型 ID使用 adapter.ExtractModelName
var providerID, modelName string
if len(body) > 0 {
unifiedID, err := clientAdapter.ExtractModelName(body, ifaceType)
if err == nil && unifiedID != "" {
pid, mn, err := modelid.ParseUnifiedModelID(unifiedID)
if err == nil {
providerID = pid
modelName = mn
}
}
}
// 构建输入 HTTPRequestSpec
inSpec := conversion.HTTPRequestSpec{
URL: nativePath,
URL: requestPath,
Method: c.Request.Method,
Headers: extractHeaders(c),
Body: body,
}
isStream := h.isStreamRequest(body, clientProtocol, nativePath)
// 只有 adapter 明确适配的接口才提取 model。未知接口不做通用 model 猜测。
if len(body) == 0 || !supportsModelExtraction(ifaceType) {
h.forwardPassthrough(c, inSpec, clientProtocol, ifaceType, isStream)
return
}
unifiedID, err := clientAdapter.ExtractModelName(body, ifaceType)
if err != nil {
if isInvalidJSONError(err) {
h.writeProxyError(c, http.StatusBadRequest, "INVALID_JSON", "请求体 JSON 格式错误")
return
}
h.forwardPassthrough(c, inSpec, clientProtocol, ifaceType, isStream)
return
}
providerID, modelName, err := modelid.ParseUnifiedModelID(unifiedID)
if err != nil {
// 原始模型名兼容透传:非统一模型 ID 不参与路由。
h.forwardPassthrough(c, inSpec, clientProtocol, ifaceType, isStream)
return
}
if providerID == "" || modelName == "" {
h.forwardPassthrough(c, inSpec, clientProtocol, ifaceType, isStream)
return
}
// 路由
routeResult, err := h.routingService.RouteByModelName(providerID, modelName)
if err != nil {
// GET 请求或无法提取 model 时,直接转发到上游
if len(body) == 0 || modelName == "" {
h.forwardPassthrough(c, inSpec, clientProtocol)
return
}
h.writeError(c, err, clientProtocol)
h.writeRouteError(c, err)
return
}
@@ -143,9 +155,6 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
routeResult.Model.ModelName, // 上游模型名,用于请求改写
)
// 判断是否流式
isStream := h.isStreamRequest(body, clientProtocol, nativePath)
// 计算统一模型 ID用于响应覆写
unifiedModelID := routeResult.Model.UnifiedModelID()
@@ -156,6 +165,28 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
}
}
func supportsModelExtraction(ifaceType conversion.InterfaceType) bool {
switch ifaceType {
case conversion.InterfaceTypeChat, conversion.InterfaceTypeEmbeddings, conversion.InterfaceTypeRerank:
return true
default:
return false
}
}
func isInvalidJSONError(err error) bool {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
return errors.As(err, &syntaxErr) || errors.As(err, &typeErr)
}
func appendRawQuery(path, rawQuery string) string {
if rawQuery == "" {
return path
}
return path + "?" + rawQuery
}
// handleNonStream 处理非流式请求
func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPRequestSpec, clientProtocol, providerProtocol string, targetProvider *conversion.TargetProvider, routeResult *domain.RouteResult, unifiedModelID string, ifaceType conversion.InterfaceType) {
// 转换请求
@@ -170,7 +201,11 @@ func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPReq
resp, err := h.client.Send(c.Request.Context(), *outSpec)
if err != nil {
h.logger.Error("发送请求失败", zap.Error(err))
h.writeConversionError(c, err, clientProtocol)
h.writeUpstreamUnavailable(c, err)
return
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
h.writeUpstreamResponse(c, *resp)
return
}
@@ -182,15 +217,7 @@ func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPReq
return
}
// 设置响应头
for k, v := range convertedResp.Headers {
c.Header(k, v)
}
if c.GetHeader("Content-Type") == "" {
c.Header("Content-Type", "application/json")
}
c.Data(convertedResp.StatusCode, "application/json", convertedResp.Body)
h.writeConvertedResponse(c, *convertedResp)
go func() {
_ = h.statsService.Record(routeResult.Provider.ID, routeResult.Model.ModelName) //nolint:errcheck // fire-and-forget 统计记录不阻塞请求
@@ -206,15 +233,23 @@ func (h *ProxyHandler) handleStream(c *gin.Context, inSpec conversion.HTTPReques
return
}
// 创建流式转换器,传入 modelOverride跨协议场景覆写 model 字段)
streamConverter, err := h.engine.CreateStreamConverter(clientProtocol, providerProtocol, unifiedModelID, ifaceType)
// 发送流式请求
streamResp, err := h.client.SendStream(c.Request.Context(), *outSpec)
if err != nil {
h.writeConversionError(c, err, clientProtocol)
h.writeUpstreamUnavailable(c, err)
return
}
if streamResp.StatusCode < http.StatusOK || streamResp.StatusCode >= http.StatusMultipleChoices {
h.writeUpstreamResponse(c, conversion.HTTPResponseSpec{
StatusCode: streamResp.StatusCode,
Headers: streamResp.Headers,
Body: streamResp.Body,
})
return
}
// 发送流式请求
eventChan, err := h.client.SendStream(c.Request.Context(), *outSpec)
// 创建流式转换器,传入 modelOverride跨协议场景覆写 model 字段)
streamConverter, err := h.engine.CreateStreamConverter(clientProtocol, providerProtocol, unifiedModelID, ifaceType)
if err != nil {
h.writeConversionError(c, err, clientProtocol)
return
@@ -225,8 +260,9 @@ func (h *ProxyHandler) handleStream(c *gin.Context, inSpec conversion.HTTPReques
c.Header("Connection", "keep-alive")
writer := bufio.NewWriter(c.Writer)
flushed := false
for event := range eventChan {
for event := range streamResp.Events {
if event.Error != nil {
h.logger.Error("流读取错误", zap.Error(event.Error))
break
@@ -237,6 +273,7 @@ func (h *ProxyHandler) handleStream(c *gin.Context, inSpec conversion.HTTPReques
if err := h.writeStreamChunks(writer, chunks); err != nil {
h.logger.Warn("流式响应写回失败", zap.Error(err))
}
flushed = true
break
}
@@ -246,6 +283,12 @@ func (h *ProxyHandler) handleStream(c *gin.Context, inSpec conversion.HTTPReques
break
}
}
if !flushed {
chunks := streamConverter.Flush()
if err := h.writeStreamChunks(writer, chunks); err != nil {
h.logger.Warn("流式响应写回失败", zap.Error(err))
}
}
go func() {
_ = h.statsService.Record(routeResult.Provider.ID, routeResult.Model.ModelName) //nolint:errcheck // fire-and-forget 统计记录不阻塞请求
@@ -291,7 +334,7 @@ func (h *ProxyHandler) handleModelsList(c *gin.Context, adapter conversion.Proto
models, err := h.providerService.ListEnabledModels()
if err != nil {
h.logger.Error("查询启用模型失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询模型失败"})
h.writeProxyError(c, http.StatusInternalServerError, "CONVERSION_FAILED", "查询模型失败")
return
}
@@ -313,7 +356,7 @@ func (h *ProxyHandler) handleModelsList(c *gin.Context, adapter conversion.Proto
body, err := adapter.EncodeModelsResponse(modelList)
if err != nil {
h.logger.Error("编码 Models 响应失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "编码响应失败"})
h.writeProxyError(c, http.StatusInternalServerError, "CONVERSION_FAILED", "编码响应失败")
return
}
@@ -325,17 +368,14 @@ func (h *ProxyHandler) handleModelInfo(c *gin.Context, unifiedID string, adapter
// 解析统一模型 ID
providerID, modelName, err := modelid.ParseUnifiedModelID(unifiedID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "无效的统一模型 ID 格式",
"code": "INVALID_MODEL_ID",
})
h.writeProxyError(c, http.StatusBadRequest, "INVALID_MODEL_ID", "无效的统一模型 ID 格式")
return
}
// 从数据库查询模型
model, err := h.providerService.GetModelByProviderAndName(providerID, modelName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "模型未找到"})
h.writeProxyError(c, http.StatusNotFound, "MODEL_NOT_FOUND", "模型未找到")
return
}
@@ -351,46 +391,103 @@ func (h *ProxyHandler) handleModelInfo(c *gin.Context, unifiedID string, adapter
body, err := adapter.EncodeModelInfoResponse(modelInfo)
if err != nil {
h.logger.Error("编码 ModelInfo 响应失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "编码响应失败"})
h.writeProxyError(c, http.StatusInternalServerError, "CONVERSION_FAILED", "编码响应失败")
return
}
c.Data(http.StatusOK, "application/json", body)
}
// writeConversionError 写入转换错误
// writeConversionError 写入网关层转换错误
func (h *ProxyHandler) writeConversionError(c *gin.Context, err error, clientProtocol string) {
var convErr *conversion.ConversionError
if errors.As(err, &convErr) {
body, statusCode, encodeErr := h.engine.EncodeError(convErr, clientProtocol)
if encodeErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": encodeErr.Error()})
return
}
c.Data(statusCode, "application/json", body)
statusCode, code, message := mapConversionError(convErr)
h.writeProxyError(c, statusCode, code, message)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.writeProxyError(c, http.StatusInternalServerError, "CONVERSION_FAILED", err.Error())
}
// writeError 写入路由错误
func (h *ProxyHandler) writeError(c *gin.Context, err error, clientProtocol string) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
func mapConversionError(err *conversion.ConversionError) (int, string, string) {
switch err.Code {
case conversion.ErrorCodeJSONParseError:
if phase, ok := err.Details[conversion.ErrorDetailPhase].(string); ok && phase == conversion.ErrorPhaseRequest {
return http.StatusBadRequest, "INVALID_JSON", "请求体 JSON 格式错误"
}
return http.StatusInternalServerError, "CONVERSION_FAILED", err.Message
case conversion.ErrorCodeInvalidInput,
conversion.ErrorCodeMissingRequiredField,
conversion.ErrorCodeProtocolConstraint:
return http.StatusBadRequest, "INVALID_REQUEST", err.Message
case conversion.ErrorCodeInterfaceNotSupported:
return http.StatusBadRequest, "UNSUPPORTED_INTERFACE", err.Message
case conversion.ErrorCodeUnsupportedMultimodal:
return http.StatusBadRequest, "UNSUPPORTED_MULTIMODAL", err.Message
default:
return http.StatusInternalServerError, "CONVERSION_FAILED", err.Message
}
}
func (h *ProxyHandler) writeRouteError(c *gin.Context, err error) {
if appErr, ok := appErrors.AsAppError(err); ok {
switch appErr.Code {
case appErrors.ErrModelNotFound.Code, appErrors.ErrModelDisabled.Code:
h.writeProxyError(c, appErr.HTTPStatus, "MODEL_NOT_FOUND", appErr.Message)
case appErrors.ErrProviderNotFound.Code, appErrors.ErrProviderDisabled.Code:
h.writeProxyError(c, appErr.HTTPStatus, "PROVIDER_NOT_FOUND", appErr.Message)
default:
h.writeProxyError(c, appErr.HTTPStatus, "INVALID_REQUEST", appErr.Message)
}
return
}
h.writeProxyError(c, http.StatusNotFound, "MODEL_NOT_FOUND", err.Error())
}
func (h *ProxyHandler) writeUpstreamUnavailable(c *gin.Context, err error) {
h.logger.Error("上游不可达", zap.Error(err))
h.writeProxyError(c, http.StatusBadGateway, "UPSTREAM_UNAVAILABLE", "上游服务不可达")
}
func (h *ProxyHandler) writeProxyError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{
"error": message,
"code": code,
})
}
func (h *ProxyHandler) writeConvertedResponse(c *gin.Context, resp conversion.HTTPResponseSpec) {
for k, v := range resp.Headers {
c.Header(k, v)
}
contentType := headerValue(resp.Headers, "Content-Type")
if contentType == "" {
contentType = "application/json"
}
c.Data(resp.StatusCode, contentType, resp.Body)
}
func (h *ProxyHandler) writeUpstreamResponse(c *gin.Context, resp conversion.HTTPResponseSpec) {
for k, v := range filterHopByHopHeaders(resp.Headers) {
c.Header(k, v)
}
contentType := headerValue(resp.Headers, "Content-Type")
c.Data(resp.StatusCode, contentType, resp.Body)
}
// forwardPassthrough 直接转发请求到上游(用于 GET 等无 model 的请求)
func (h *ProxyHandler) forwardPassthrough(c *gin.Context, inSpec conversion.HTTPRequestSpec, clientProtocol string) {
func (h *ProxyHandler) forwardPassthrough(c *gin.Context, inSpec conversion.HTTPRequestSpec, clientProtocol string, ifaceType conversion.InterfaceType, isStream bool) {
registry := h.engine.GetRegistry()
adapter, err := registry.Get(clientProtocol)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的协议: " + clientProtocol})
h.writeProxyError(c, http.StatusNotFound, "UNSUPPORTED_INTERFACE", "不支持的协议: "+clientProtocol)
return
}
providers, err := h.providerService.List()
if err != nil || len(providers) == 0 {
h.logger.Warn("无可用供应商转发 GET 请求", zap.String("path", inSpec.URL))
c.JSON(http.StatusNotFound, gin.H{"error": "没有可用的供应商。请先创建供应商和模型。"})
h.logger.Warn("无可用供应商转发请求", zap.String("path", inSpec.URL))
h.writeProxyError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "没有可用的供应商。请先创建供应商和模型。")
return
}
@@ -400,19 +497,18 @@ func (h *ProxyHandler) forwardPassthrough(c *gin.Context, inSpec conversion.HTTP
providerProtocol = "openai"
}
ifaceType := adapter.DetectInterfaceType(inSpec.URL)
targetProvider := conversion.NewTargetProvider(p.BaseURL, p.APIKey, "")
var outSpec *conversion.HTTPRequestSpec
if clientProtocol == providerProtocol {
upstreamURL := p.BaseURL + inSpec.URL
upstreamPath := adapter.BuildUrl(stripRawQuery(inSpec.URL), ifaceType)
upstreamPath = appendRawQuery(upstreamPath, rawQueryFromPath(inSpec.URL))
headers := adapter.BuildHeaders(targetProvider)
if _, ok := headers["Content-Type"]; !ok {
headers["Content-Type"] = "application/json"
}
outSpec = &conversion.HTTPRequestSpec{
URL: upstreamURL,
URL: joinBaseURL(p.BaseURL, upstreamPath),
Method: inSpec.Method,
Headers: headers,
Body: inSpec.Body,
@@ -425,9 +521,18 @@ func (h *ProxyHandler) forwardPassthrough(c *gin.Context, inSpec conversion.HTTP
}
}
if isStream {
h.forwardStream(c, *outSpec, clientProtocol, providerProtocol, ifaceType)
return
}
resp, err := h.client.Send(c.Request.Context(), *outSpec)
if err != nil {
h.writeConversionError(c, err, clientProtocol)
h.writeUpstreamUnavailable(c, err)
return
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
h.writeUpstreamResponse(c, *resp)
return
}
@@ -437,13 +542,111 @@ func (h *ProxyHandler) forwardPassthrough(c *gin.Context, inSpec conversion.HTTP
return
}
for k, v := range convertedResp.Headers {
c.Header(k, v)
h.writeConvertedResponse(c, *convertedResp)
}
func (h *ProxyHandler) forwardStream(c *gin.Context, outSpec conversion.HTTPRequestSpec, clientProtocol, providerProtocol string, ifaceType conversion.InterfaceType) {
streamResp, err := h.client.SendStream(c.Request.Context(), outSpec)
if err != nil {
h.writeUpstreamUnavailable(c, err)
return
}
if c.GetHeader("Content-Type") == "" {
c.Header("Content-Type", "application/json")
if streamResp.StatusCode < http.StatusOK || streamResp.StatusCode >= http.StatusMultipleChoices {
h.writeUpstreamResponse(c, conversion.HTTPResponseSpec{
StatusCode: streamResp.StatusCode,
Headers: streamResp.Headers,
Body: streamResp.Body,
})
return
}
c.Data(convertedResp.StatusCode, "application/json", convertedResp.Body)
streamConverter, err := h.engine.CreateStreamConverter(clientProtocol, providerProtocol, "", ifaceType)
if err != nil {
h.writeConversionError(c, err, clientProtocol)
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
writer := bufio.NewWriter(c.Writer)
flushed := false
for event := range streamResp.Events {
if event.Error != nil {
h.logger.Error("透传流读取错误", zap.Error(event.Error))
break
}
if event.Done {
chunks := streamConverter.Flush()
if err := h.writeStreamChunks(writer, chunks); err != nil {
h.logger.Warn("透传流式响应写回失败", zap.Error(err))
}
flushed = true
break
}
chunks := streamConverter.ProcessChunk(event.Data)
if err := h.writeStreamChunks(writer, chunks); err != nil {
h.logger.Warn("透传流式响应写回失败", zap.Error(err))
break
}
}
if !flushed {
chunks := streamConverter.Flush()
if err := h.writeStreamChunks(writer, chunks); err != nil {
h.logger.Warn("透传流式响应写回失败", zap.Error(err))
}
}
}
func stripRawQuery(path string) string {
pathOnly, _, _ := strings.Cut(path, "?")
return pathOnly
}
func rawQueryFromPath(path string) string {
_, rawQuery, found := strings.Cut(path, "?")
if !found {
return ""
}
return rawQuery
}
func joinBaseURL(baseURL, path string) string {
return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(path, "/")
}
func headerValue(headers map[string]string, key string) string {
for k, v := range headers {
if strings.EqualFold(k, key) {
return v
}
}
return ""
}
func filterHopByHopHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return nil
}
hopByHop := map[string]struct{}{
"connection": {},
"transfer-encoding": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"te": {},
"trailer": {},
"upgrade": {},
}
filtered := make(map[string]string, len(headers))
for k, v := range headers {
if _, skip := hopByHop[strings.ToLower(k)]; skip {
continue
}
filtered[k] = v
}
return filtered
}
// extractHeaders 从 Gin context 提取请求头

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -73,7 +74,7 @@ func TestProxyHandler_HandleProxy_NonStreamSuccess(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -92,8 +93,8 @@ func TestProxyHandler_HandleProxy_NonStreamSuccess(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -109,20 +110,20 @@ func TestProxyHandler_HandleProxy_RoutingError_WithBody(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(nil, appErrors.ErrModelNotFound)
routingSvc.EXPECT().RouteByModelName("unknown", "model").Return(nil, appErrors.ErrModelNotFound)
providerSvc := mocks.NewMockProviderService(ctrl)
providerSvc.EXPECT().List().Return(nil, nil)
client := mocks.NewMockProviderClient(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"unknown","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 404, w.Code)
assert.Contains(t, w.Body.String(), "MODEL_NOT_FOUND")
}
func TestProxyHandler_HandleProxy_ConversionError(t *testing.T) {
@@ -131,7 +132,7 @@ func TestProxyHandler_HandleProxy_ConversionError(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -145,11 +146,12 @@ func TestProxyHandler_HandleProxy_ConversionError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 500, w.Code)
assert.Equal(t, 502, w.Code)
assert.JSONEq(t, `{"error":"上游服务不可达","code":"UPSTREAM_UNAVAILABLE"}`, w.Body.String())
}
func TestProxyHandler_HandleProxy_ClientSendError(t *testing.T) {
@@ -158,7 +160,7 @@ func TestProxyHandler_HandleProxy_ClientSendError(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -172,11 +174,12 @@ func TestProxyHandler_HandleProxy_ClientSendError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 500, w.Code)
assert.Equal(t, 502, w.Code)
assert.JSONEq(t, `{"error":"上游服务不可达","code":"UPSTREAM_UNAVAILABLE"}`, w.Body.String())
}
func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) {
@@ -185,12 +188,12 @@ func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
ch := make(chan provider.StreamEvent, 10)
go func() {
defer close(ch)
@@ -199,7 +202,7 @@ func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) {
ch <- provider.StreamEvent{Data: []byte("data: [DONE]\n\n")}
ch <- provider.StreamEvent{Done: true}
}()
return ch, nil
return &provider.StreamResponse{StatusCode: 200, Headers: map[string]string{"Content-Type": "text/event-stream"}, Events: ch}, nil
})
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
@@ -208,13 +211,14 @@ func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type"))
assert.Contains(t, w.Body.String(), "Hello")
assert.Contains(t, w.Body.String(), "p1/gpt-4")
}
func TestProxyHandler_HandleProxy_StreamError(t *testing.T) {
@@ -223,12 +227,12 @@ func TestProxyHandler_HandleProxy_StreamError(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
return nil, context.DeadlineExceeded
})
providerSvc := mocks.NewMockProviderService(ctrl)
@@ -237,11 +241,12 @@ func TestProxyHandler_HandleProxy_StreamError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 500, w.Code)
assert.Equal(t, 502, w.Code)
assert.JSONEq(t, `{"error":"上游服务不可达","code":"UPSTREAM_UNAVAILABLE"}`, w.Body.String())
}
func TestProxyHandler_ForwardPassthrough_GET(t *testing.T) {
@@ -261,8 +266,8 @@ func TestProxyHandler_ForwardPassthrough_GET(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -282,11 +287,11 @@ func TestProxyHandler_ForwardPassthrough_UnsupportedProtocol(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "unknown"}, {Key: "path", Value: "/models"}}
c.Params = gin.Params{{Key: "protocol", Value: "unknown"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/unknown/models", nil)
h.HandleProxy(c)
assert.Equal(t, 400, w.Code)
assert.Equal(t, 404, w.Code)
}
func TestProxyHandler_ForwardPassthrough_NoProviders(t *testing.T) {
@@ -304,8 +309,8 @@ func TestProxyHandler_ForwardPassthrough_NoProviders(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -329,7 +334,7 @@ func TestProxyHandler_HandleProxy_ProviderProtocolDefault(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -347,8 +352,8 @@ func TestProxyHandler_HandleProxy_ProviderProtocolDefault(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -371,6 +376,7 @@ func TestProxyHandler_WriteConversionError_NonConversionError(t *testing.T) {
h.writeConversionError(c, context.DeadlineExceeded, "openai")
assert.Equal(t, 500, w.Code)
assert.JSONEq(t, `{"error":"context deadline exceeded","code":"CONVERSION_FAILED"}`, w.Body.String())
}
func TestProxyHandler_WriteConversionError_ConversionError(t *testing.T) {
@@ -390,7 +396,40 @@ func TestProxyHandler_WriteConversionError_ConversionError(t *testing.T) {
convErr := conversion.NewConversionError(conversion.ErrorCodeInvalidInput, "bad request")
h.writeConversionError(c, convErr, "openai")
assert.Equal(t, 500, w.Code)
assert.Equal(t, 400, w.Code)
assert.JSONEq(t, `{"error":"bad request","code":"INVALID_REQUEST"}`, w.Body.String())
}
func TestProxyHandler_WriteConversionError_JSONPhase(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
client := mocks.NewMockProviderClient(ctrl)
routingSvc := mocks.NewMockRoutingService(ctrl)
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
t.Run("request json parse error", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/", nil)
h.writeConversionError(c, conversion.NewRequestJSONParseError("解码请求失败", context.Canceled), "openai")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.JSONEq(t, `{"error":"请求体 JSON 格式错误","code":"INVALID_JSON"}`, w.Body.String())
})
t.Run("response json parse error", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/", nil)
h.writeConversionError(c, conversion.NewResponseJSONParseError("解码响应失败", context.Canceled), "openai")
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.JSONEq(t, `{"error":"解码响应失败","code":"CONVERSION_FAILED"}`, w.Body.String())
})
}
func TestProxyHandler_HandleProxy_EmptyBody(t *testing.T) {
@@ -410,8 +449,8 @@ func TestProxyHandler_HandleProxy_EmptyBody(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -423,19 +462,19 @@ func TestProxyHandler_HandleStream_MidStreamError(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
ch := make(chan provider.StreamEvent, 10)
go func() {
defer close(ch)
ch <- provider.StreamEvent{Data: []byte("data: {\"id\":\"1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\n")}
ch <- provider.StreamEvent{Error: fmt.Errorf("connection reset by peer")}
}()
return ch, nil
return &provider.StreamResponse{StatusCode: 200, Headers: map[string]string{"Content-Type": "text/event-stream"}, Events: ch}, nil
})
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
@@ -444,8 +483,8 @@ func TestProxyHandler_HandleStream_MidStreamError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -460,12 +499,12 @@ func TestProxyHandler_HandleStream_FlushOutput(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
ch := make(chan provider.StreamEvent, 10)
go func() {
defer close(ch)
@@ -473,7 +512,7 @@ func TestProxyHandler_HandleStream_FlushOutput(t *testing.T) {
ch <- provider.StreamEvent{Data: []byte("data: [DONE]\n\n")}
ch <- provider.StreamEvent{Done: true}
}()
return ch, nil
return &provider.StreamResponse{StatusCode: 200, Headers: map[string]string{"Content-Type": "text/event-stream"}, Events: ch}, nil
})
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
@@ -482,8 +521,8 @@ func TestProxyHandler_HandleStream_FlushOutput(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -505,7 +544,7 @@ func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) {
require.NoError(t, err)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "nonexistent", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -516,8 +555,8 @@ func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 500, w.Code)
@@ -532,7 +571,7 @@ func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) {
require.NoError(t, registry.Register(openai.NewAdapter()))
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "nonexistent", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -543,8 +582,8 @@ func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 500, w.Code)
@@ -560,7 +599,7 @@ func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) {
require.NoError(t, registry.Register(anthropic.NewAdapter()))
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "anthropic", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "claude-3", Enabled: true},
}, nil)
@@ -578,8 +617,8 @@ func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 500, w.Code)
@@ -591,7 +630,7 @@ func TestProxyHandler_HandleNonStream_ResponseHeaders(t *testing.T) {
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("", "").Return(&domain.RouteResult{
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
@@ -610,8 +649,8 @@ func TestProxyHandler_HandleNonStream_ResponseHeaders(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -642,8 +681,8 @@ func TestProxyHandler_ForwardPassthrough_CrossProtocol(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -666,8 +705,8 @@ func TestProxyHandler_ForwardPassthrough_NoBody_NoModel(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -690,10 +729,10 @@ func TestIsStreamRequest_EdgeCases(t *testing.T) {
path string
expected bool
}{
{"stream at end of JSON", `{"messages":[],"stream":true}`, "/chat/completions", true},
{"stream with spaces", `{"stream" : true}`, "/chat/completions", true},
{"stream embedded in string value", `{"model":"stream:true"}`, "/chat/completions", false},
{"empty body", "", "/chat/completions", false},
{"stream at end of JSON", `{"messages":[],"stream":true}`, "/v1/chat/completions", true},
{"stream with spaces", `{"stream" : true}`, "/v1/chat/completions", true},
{"stream embedded in string value", `{"model":"stream:true"}`, "/v1/chat/completions", false},
{"empty body", "", "/v1/chat/completions", false},
{"stream true embeddings", `{"model":"text-emb","stream":true}`, "/v1/embeddings", false},
}
@@ -720,8 +759,9 @@ func TestProxyHandler_WriteError_RouteError(t *testing.T) {
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/", nil)
h.writeError(c, fmt.Errorf("model not found"), "openai")
h.writeRouteError(c, fmt.Errorf("model not found"))
assert.Equal(t, 404, w.Code)
assert.Contains(t, w.Body.String(), "MODEL_NOT_FOUND")
}
func TestProxyHandler_HandleProxy_RouteEmptyBody_NoModel(t *testing.T) {
@@ -741,8 +781,8 @@ func TestProxyHandler_HandleProxy_RouteEmptyBody_NoModel(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -765,35 +805,35 @@ func TestIsStreamRequest(t *testing.T) {
name: "stream true",
body: []byte(`{"model": "gpt-4", "stream": true}`),
clientProtocol: "openai",
nativePath: "/chat/completions",
nativePath: "/v1/chat/completions",
expected: true,
},
{
name: "stream false",
body: []byte(`{"model": "gpt-4", "stream": false}`),
clientProtocol: "openai",
nativePath: "/chat/completions",
nativePath: "/v1/chat/completions",
expected: false,
},
{
name: "no stream field",
body: []byte(`{"model": "gpt-4"}`),
clientProtocol: "openai",
nativePath: "/chat/completions",
nativePath: "/v1/chat/completions",
expected: false,
},
{
name: "invalid json",
body: []byte(`{invalid}`),
clientProtocol: "openai",
nativePath: "/chat/completions",
nativePath: "/v1/chat/completions",
expected: false,
},
{
name: "not chat endpoint",
body: []byte(`{"model": "gpt-4", "stream": true}`),
clientProtocol: "openai",
nativePath: "/models",
nativePath: "/v1/models",
expected: false,
},
{
@@ -831,8 +871,8 @@ func TestProxyHandler_HandleProxy_Models_LocalAggregation(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -862,8 +902,8 @@ func TestProxyHandler_HandleProxy_ModelInfo_LocalQuery(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/openai/gpt-4"}}
c.Request = httptest.NewRequest("GET", "/openai/models/openai/gpt-4", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models/openai/gpt-4"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models/openai/gpt-4", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -896,8 +936,8 @@ func TestProxyHandler_HandleProxy_Models_EmptySuffix_ForwardPassthrough(t *testi
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/"}}
c.Request = httptest.NewRequest("GET", "/openai/models/", nil)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models/"}}
c.Request = httptest.NewRequest("GET", "/openai/v1/models/", nil)
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -934,8 +974,8 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_UnifiedID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -972,8 +1012,8 @@ func TestProxyHandler_HandleProxy_CrossProtocol_NonStream_UnifiedID(t *testing.T
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -994,7 +1034,7 @@ func TestProxyHandler_HandleProxy_CrossProtocol_Stream_UnifiedID(t *testing.T) {
Model: &domain.Model{ID: "m1", ProviderID: "anthropic_p", ModelName: "claude-3", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
ch := make(chan provider.StreamEvent, 10)
go func() {
defer close(ch)
@@ -1012,7 +1052,7 @@ data: {"type":"message_stop"}
`)}
ch <- provider.StreamEvent{Done: true}
}()
return ch, nil
return &provider.StreamResponse{StatusCode: 200, Headers: map[string]string{"Content-Type": "text/event-stream"}, Events: ch}, nil
})
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
@@ -1021,8 +1061,8 @@ data: {"type":"message_stop"}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -1059,8 +1099,8 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_Fidelity(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}],"custom_param":"should_be_preserved"}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}],"custom_param":"should_be_preserved"}`)))
h.HandleProxy(c)
assert.Equal(t, 200, w.Code)
@@ -1090,8 +1130,8 @@ func TestProxyHandler_HandleProxy_UnifiedID_ModelNotFound(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
assert.Equal(t, 404, w.Code)
@@ -1100,3 +1140,314 @@ func TestProxyHandler_HandleProxy_UnifiedID_ModelNotFound(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Contains(t, resp, "error")
}
func TestProxyHandler_HandleProxy_OpenAIAndAnthropicNativePaths(t *testing.T) {
tests := []struct {
name string
protocol string
path string
requestPath string
baseURL string
expectedURL string
body string
responseBody string
responseModel string
}{
{
name: "openai path keeps v1 after gateway prefix",
protocol: "openai",
path: "/v1/chat/completions",
requestPath: "/openai/v1/chat/completions",
baseURL: "https://api.test.com/v1",
expectedURL: "https://api.test.com/v1/chat/completions",
body: `{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`,
responseBody: `{"id":"r1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
responseModel: "p1/gpt-4",
},
{
name: "anthropic path keeps v1 after gateway prefix",
protocol: "anthropic",
path: "/v1/messages",
requestPath: "/anthropic/v1/messages",
baseURL: "https://api.anthropic.test",
expectedURL: "https://api.anthropic.test/v1/messages",
body: `{"model":"p1/gpt-4","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`,
responseBody: `{"id":"msg-1","type":"message","role":"assistant","model":"gpt-4","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`,
responseModel: "p1/gpt-4",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: tt.baseURL, Protocol: tt.protocol, Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().Send(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
assert.Equal(t, tt.expectedURL, spec.URL)
return &conversion.HTTPResponseSpec{
StatusCode: http.StatusOK,
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(tt.responseBody),
}, nil
})
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
statsSvc.EXPECT().Record(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: tt.protocol}, {Key: "path", Value: tt.path}}
c.Request = httptest.NewRequest("POST", tt.requestPath, bytes.NewReader([]byte(tt.body)))
h.HandleProxy(c)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), tt.responseModel)
})
}
}
func TestProxyHandler_UpstreamNon2xx_Passthrough(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com/v1", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().Send(gomock.Any(), gomock.Any()).Return(&conversion.HTTPResponseSpec{
StatusCode: http.StatusTooManyRequests,
Headers: map[string]string{
"Content-Type": "application/json",
"X-Upstream-Error": "rate-limit",
"Transfer-Encoding": "chunked",
},
Body: []byte(`{"error":{"message":"rate limited"}}`),
}, nil)
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
h.HandleProxy(c)
require.Equal(t, http.StatusTooManyRequests, w.Code)
assert.JSONEq(t, `{"error":{"message":"rate limited"}}`, w.Body.String())
assert.Equal(t, "rate-limit", w.Header().Get("X-Upstream-Error"))
assert.Empty(t, w.Header().Get("Transfer-Encoding"))
}
func TestProxyHandler_StreamUpstreamNon2xx_Passthrough(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com/v1", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).Return(&provider.StreamResponse{
StatusCode: http.StatusServiceUnavailable,
Headers: map[string]string{"Content-Type": "application/json", "Connection": "close"},
Body: []byte(`{"error":"upstream down"}`),
}, nil)
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.JSONEq(t, `{"error":"upstream down"}`, w.Body.String())
assert.Empty(t, w.Header().Get("Connection"))
}
func TestFilterHopByHopHeaders(t *testing.T) {
filtered := filterHopByHopHeaders(map[string]string{
"Connection": "close",
"Transfer-Encoding": "chunked",
"Keep-Alive": "timeout=5",
"Proxy-Authenticate": "Basic",
"Proxy-Authorization": "Basic token",
"TE": "trailers",
"Trailer": "Expires",
"Upgrade": "websocket",
"Content-Type": "application/json",
"X-Request-ID": "req-1",
})
assert.Equal(t, map[string]string{
"Content-Type": "application/json",
"X-Request-ID": "req-1",
}, filtered)
}
func TestProxyHandler_UnknownInterface_DoesNotGuessModel(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
providerSvc := mocks.NewMockProviderService(ctrl)
providerSvc.EXPECT().List().Return([]domain.Provider{
{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com/v1", Protocol: "openai", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().Send(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
assert.Equal(t, "https://api.test.com/v1/unknown?trace=1", spec.URL)
assert.JSONEq(t, `{"model":"p1/gpt-4","payload":true}`, string(spec.Body))
return &conversion.HTTPResponseSpec{
StatusCode: http.StatusOK,
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(`{"ok":true}`),
}, nil
})
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/unknown"}}
c.Request = httptest.NewRequest("POST", "/openai/unknown?trace=1", bytes.NewReader([]byte(`{"model":"p1/gpt-4","payload":true}`)))
h.HandleProxy(c)
require.Equal(t, http.StatusOK, w.Code)
assert.JSONEq(t, `{"ok":true}`, w.Body.String())
}
func TestProxyHandler_InvalidJSON_UsesGatewayError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
client := mocks.NewMockProviderClient(ctrl)
routingSvc := mocks.NewMockRoutingService(ctrl)
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":`)))
h.HandleProxy(c)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.JSONEq(t, `{"error":"请求体 JSON 格式错误","code":"INVALID_JSON"}`, w.Body.String())
}
func TestProxyHandler_CrossProtocolMultimodal_Unsupported(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("anthropic_p", "claude").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "anthropic_p", Name: "Anthropic", APIKey: "sk-test", BaseURL: "https://api.anthropic.test", Protocol: "anthropic", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "anthropic_p", ModelName: "claude", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
body := []byte(`{"model":"anthropic_p/claude","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
h.HandleProxy(c)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "UNSUPPORTED_MULTIMODAL")
}
func TestProxyHandler_SameProtocolMultimodal_SmartPassthrough(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
routingSvc.EXPECT().RouteByModelName("p1", "gpt-4").Return(&domain.RouteResult{
Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com/v1", Protocol: "openai", Enabled: true},
Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().Send(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
assert.Contains(t, string(spec.Body), "image_url")
assert.Contains(t, string(spec.Body), `"model":"gpt-4"`)
return &conversion.HTTPResponseSpec{
StatusCode: http.StatusOK,
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(`{"id":"r1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`),
}, nil
})
providerSvc := mocks.NewMockProviderService(ctrl)
statsSvc := mocks.NewMockStatsService(ctrl)
statsSvc.EXPECT().Record(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
body := []byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
h.HandleProxy(c)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "p1/gpt-4")
}
func TestProxyHandler_RawStreamPassthrough_PreservesSSEFrames(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
engine := setupProxyEngine(t)
routingSvc := mocks.NewMockRoutingService(ctrl)
providerSvc := mocks.NewMockProviderService(ctrl)
providerSvc.EXPECT().List().Return([]domain.Provider{
{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com/v1", Protocol: "openai", Enabled: true},
}, nil)
client := mocks.NewMockProviderClient(ctrl)
client.EXPECT().SendStream(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
assert.Contains(t, string(spec.Body), `"model":"gpt-4"`)
ch := make(chan provider.StreamEvent, 3)
go func() {
defer close(ch)
ch <- provider.StreamEvent{Data: []byte("data: {\"model\":\"gpt-4\",\"choices\":[]}\n\n")}
ch <- provider.StreamEvent{Data: []byte("data: [DONE]\n\n")}
ch <- provider.StreamEvent{Done: true}
}()
return &provider.StreamResponse{StatusCode: http.StatusOK, Headers: map[string]string{"Content-Type": "text/event-stream"}, Events: ch}, nil
})
statsSvc := mocks.NewMockStatsService(ctrl)
h := newTestProxyHandler(engine, client, routingSvc, providerSvc, statsSvc)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
h.HandleProxy(c)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "data: {\"model\":\"gpt-4\",\"choices\":[]}\n\ndata: [DONE]\n\n", w.Body.String())
}

View File

@@ -0,0 +1,223 @@
package handler
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"nex/backend/internal/config"
appErrors "nex/backend/pkg/errors"
)
type SettingsHandler struct {
runtimeCfg *config.Config
mode string
editable bool
configPath string
}
func NewSettingsHandler(runtimeCfg *config.Config, mode string, editable bool, configPath string) *SettingsHandler {
return &SettingsHandler{
runtimeCfg: runtimeCfg,
mode: mode,
editable: editable,
configPath: configPath,
}
}
type serverConfigDTO struct {
Port int `json:"port"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
}
type databaseConfigDTO struct {
Driver string `json:"driver"`
Path string `json:"path"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DBName string `json:"dbname"`
MaxIdleConns int `json:"max_idle_conns"`
MaxOpenConns int `json:"max_open_conns"`
ConnMaxLifetime string `json:"conn_max_lifetime"`
}
type logConfigDTO struct {
Level string `json:"level"`
Path string `json:"path"`
MaxSize int `json:"max_size"`
MaxBackups int `json:"max_backups"`
MaxAge int `json:"max_age"`
Compress bool `json:"compress"`
}
type startupSettingsDTO struct {
Server serverConfigDTO `json:"server"`
Database databaseConfigDTO `json:"database"`
Log logConfigDTO `json:"log"`
}
type startupSettingsResponse struct {
Mode string `json:"mode"`
Editable bool `json:"editable"`
ConfigPath string `json:"config_path"`
RestartRequired bool `json:"restart_required"`
Config startupSettingsDTO `json:"config"`
}
func configToDTO(cfg *config.Config) startupSettingsDTO {
return startupSettingsDTO{
Server: serverConfigDTO{
Port: cfg.Server.Port,
ReadTimeout: cfg.Server.ReadTimeout.String(),
WriteTimeout: cfg.Server.WriteTimeout.String(),
},
Database: databaseConfigDTO{
Driver: cfg.Database.Driver,
Path: cfg.Database.Path,
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Password: cfg.Database.Password,
DBName: cfg.Database.DBName,
MaxIdleConns: cfg.Database.MaxIdleConns,
MaxOpenConns: cfg.Database.MaxOpenConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime.String(),
},
Log: logConfigDTO{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
MaxBackups: cfg.Log.MaxBackups,
MaxAge: cfg.Log.MaxAge,
Compress: cfg.Log.Compress,
},
}
}
func dtoToConfig(dto startupSettingsDTO) (*config.Config, error) {
readTimeout, err := time.ParseDuration(dto.Server.ReadTimeout)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "read_timeout 格式错误,例如 30s")
}
writeTimeout, err := time.ParseDuration(dto.Server.WriteTimeout)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "write_timeout 格式错误,例如 30s")
}
connMaxLifetime, err := time.ParseDuration(dto.Database.ConnMaxLifetime)
if err != nil {
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "conn_max_lifetime 格式错误,例如 1h")
}
return &config.Config{
Server: config.ServerConfig{
Port: dto.Server.Port,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
},
Database: config.DatabaseConfig{
Driver: dto.Database.Driver,
Path: dto.Database.Path,
Host: dto.Database.Host,
Port: dto.Database.Port,
User: dto.Database.User,
Password: dto.Database.Password,
DBName: dto.Database.DBName,
MaxIdleConns: dto.Database.MaxIdleConns,
MaxOpenConns: dto.Database.MaxOpenConns,
ConnMaxLifetime: connMaxLifetime,
},
Log: config.LogConfig{
Level: dto.Log.Level,
Path: dto.Log.Path,
MaxSize: dto.Log.MaxSize,
MaxBackups: dto.Log.MaxBackups,
MaxAge: dto.Log.MaxAge,
Compress: dto.Log.Compress,
},
}, nil
}
func (h *SettingsHandler) GetStartupSettings(c *gin.Context) {
var cfg *config.Config
var configPath string
if h.mode == "desktop" {
desktopCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
if err != nil {
writeError(c, err)
return
}
cfg = desktopCfg
configPath = h.configPath
} else {
cfg = h.runtimeCfg
configPath = h.configPath
}
c.JSON(http.StatusOK, startupSettingsResponse{
Mode: h.mode,
Editable: h.editable,
ConfigPath: configPath,
RestartRequired: h.editable,
Config: configToDTO(cfg),
})
}
func (h *SettingsHandler) SaveStartupSettings(c *gin.Context) {
if !h.editable {
c.JSON(http.StatusForbidden, gin.H{
"error": "server 模式下不允许保存启动参数",
"code": "forbidden",
})
return
}
var req struct {
Config startupSettingsDTO `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "无效的请求格式",
})
return
}
cfg, err := dtoToConfig(req.Config)
if err != nil {
writeError(c, err)
return
}
if err := cfg.Validate(); err != nil {
writeError(c, err)
return
}
if err := config.SaveConfigToPath(cfg, h.configPath); err != nil {
if errors.Is(err, appErrors.ErrInvalidRequest) {
writeError(c, err)
return
}
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
return
}
savedCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
if err != nil {
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
return
}
c.JSON(http.StatusOK, startupSettingsResponse{
Mode: h.mode,
Editable: h.editable,
ConfigPath: h.configPath,
RestartRequired: true,
Config: configToDTO(savedCfg),
})
}

View File

@@ -0,0 +1,510 @@
package handler
import (
"bytes"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"nex/backend/internal/config"
)
func init() {
gin.SetMode(gin.TestMode)
}
func createTestConfig(t *testing.T) (*config.Config, string) {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
cfg := config.DefaultConfig()
cfg.Database.Path = filepath.Join(dir, "test.db")
cfg.Log.Path = filepath.Join(dir, "log")
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
return cfg, configPath
}
func TestSettingsHandler_GetStartupSettings_Desktop(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "desktop", resp.Mode)
assert.True(t, resp.Editable)
assert.True(t, resp.RestartRequired)
assert.Equal(t, configPath, resp.ConfigPath)
assert.Equal(t, cfg.Server.Port, resp.Config.Server.Port)
assert.Equal(t, "30s", resp.Config.Server.ReadTimeout)
assert.Equal(t, cfg.Database.Driver, resp.Config.Database.Driver)
}
func TestSettingsHandler_GetStartupSettings_Server(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "server", false, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "server", resp.Mode)
assert.False(t, resp.Editable)
assert.False(t, resp.RestartRequired)
assert.Equal(t, configPath, resp.ConfigPath)
}
func TestSettingsHandler_SaveStartupSettings_Desktop(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
newPort := 9999
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": newPort,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(t.TempDir(), "new.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(t.TempDir(), "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, newPort, resp.Config.Server.Port)
assert.True(t, resp.Editable)
assert.True(t, resp.RestartRequired)
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, newPort, savedCfg.Server.Port)
}
func TestSettingsHandler_SaveStartupSettings_Server_Forbidden(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "server", false, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9999,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 403, w.Code)
assert.Contains(t, w.Body.String(), "不允许保存")
}
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidConfig(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
originalData, err := os.ReadFile(configPath)
require.NoError(t, err)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 0,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
currentData, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Equal(t, originalData, currentData)
}
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidDuration(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "not-a-duration",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": "/tmp/test.db",
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": "/tmp/log",
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "read_timeout")
}
func TestSettingsHandler_SaveStartupSettings_Desktop_CreatesConfigFile(t *testing.T) {
cfg := config.DefaultConfig()
dir := t.TempDir()
configPath := filepath.Join(dir, "nex", "config.yaml")
_, err := os.Stat(configPath)
assert.True(t, os.IsNotExist(err))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(dir, "test.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(dir, "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
_, err = os.Stat(configPath)
assert.NoError(t, err)
}
func TestSettingsHandler_SaveStartupSettings_Desktop_PasswordIncluded(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "30s",
},
"database": map[string]interface{}{
"driver": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "secret123",
"dbname": "nex",
"path": "",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(t.TempDir(), "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "secret123", resp.Config.Database.Password)
}
func TestSettingsHandler_GetStartupSettings_DesktopReadsConfigFile(t *testing.T) {
cfg, configPath := createTestConfig(t)
savedCfg := config.DefaultConfig()
savedCfg.Server.Port = 5555
data, err := yaml.Marshal(savedCfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(configPath, data, 0o600))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 5555, resp.Config.Server.Port)
}
func TestSettingsHandler_GetStartupSettings_ServerReturnsRuntime(t *testing.T) {
cfg, configPath := createTestConfig(t)
cfg.Server.Port = 7777
h := NewSettingsHandler(cfg, "server", false, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, 7777, resp.Config.Server.Port)
}
func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader([]byte("{invalid")))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 400, w.Code)
}
func TestSettingsHandler_GetStartupSettings_DurationNormalization(t *testing.T) {
cfg, configPath := createTestConfig(t)
yamlContent := `
server:
port: 9826
read_timeout: 30s
write_timeout: 1m
database:
driver: sqlite
path: ` + cfg.Database.Path + `
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 30m
log:
level: info
path: ` + cfg.Log.Path + `
max_size: 100
max_backups: 10
max_age: 30
compress: true
`
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0o600))
h := NewSettingsHandler(cfg, "desktop", true, configPath)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
h.GetStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "1m0s", resp.Config.Server.WriteTimeout)
assert.Equal(t, "30m0s", resp.Config.Database.ConnMaxLifetime)
}
func TestSettingsHandler_SaveStartupSettings_StandardDurationRoundTrip(t *testing.T) {
cfg, configPath := createTestConfig(t)
h := NewSettingsHandler(cfg, "desktop", true, configPath)
tmpDir := t.TempDir()
body, _ := json.Marshal(map[string]interface{}{
"config": map[string]interface{}{
"server": map[string]interface{}{
"port": 9826,
"read_timeout": "30s",
"write_timeout": "1m0s",
},
"database": map[string]interface{}{
"driver": "sqlite",
"path": filepath.Join(tmpDir, "test.db"),
"port": 3306,
"dbname": "nex",
"max_idle_conns": 10,
"max_open_conns": 100,
"conn_max_lifetime": "1h0m0s",
},
"log": map[string]interface{}{
"level": "info",
"path": filepath.Join(tmpDir, "log"),
"max_size": 100,
"max_backups": 10,
"max_age": 30,
"compress": true,
},
},
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.SaveStartupSettings(c)
assert.Equal(t, 200, w.Code)
var resp startupSettingsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "1m0s", resp.Config.Server.WriteTimeout)
assert.Equal(t, "1h0m0s", resp.Config.Database.ConnMaxLifetime)
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 1*time.Hour, savedCfg.Database.ConnMaxLifetime)
}

View File

@@ -0,0 +1,26 @@
package handler
import (
"net/http"
"nex/backend/pkg/buildinfo"
"github.com/gin-gonic/gin"
)
// VersionHandler 提供后端构建版本信息。
type VersionHandler struct{}
// NewVersionHandler 创建版本信息处理器。
func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
}
// GetVersion 返回构建注入的版本元数据。
func (h *VersionHandler) GetVersion(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": buildinfo.Version(),
"commit": buildinfo.Commit(),
"build_time": buildinfo.BuildTime(),
})
}

View File

@@ -0,0 +1,31 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVersionHandler_GetVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewVersionHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/version", nil)
h.GetVersion(c)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "dev", result["version"])
assert.Equal(t, "unknown", result["commit"])
assert.Equal(t, "unknown", result["build_time"])
}

View File

@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"strings"
"syscall"
"time"
@@ -43,6 +44,14 @@ type StreamEvent struct {
Done bool
}
// StreamResponse 表示上游流式 HTTP 响应。
type StreamResponse struct {
StatusCode int
Headers map[string]string
Body []byte
Events <-chan StreamEvent
}
// Client 协议无关的供应商客户端
type Client struct {
httpClient *http.Client
@@ -55,7 +64,7 @@ type Client struct {
//go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=../../tests/mocks/mock_provider_client.go -package=mocks
type ProviderClient interface {
Send(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error)
SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan StreamEvent, error)
SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (*StreamResponse, error)
}
// NewClient 创建供应商客户端
@@ -116,7 +125,7 @@ func (c *Client) Send(ctx context.Context, spec conversion.HTTPRequestSpec) (*co
}
// SendStream 发送流式请求
func (c *Client) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan StreamEvent, error) {
func (c *Client) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (*StreamResponse, error) {
var bodyReader io.Reader
if len(spec.Body) > 0 {
bodyReader = bytes.NewReader(spec.Body)
@@ -139,23 +148,29 @@ func (c *Client) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec
return nil, pkgErrors.ErrRequestSend.WithCause(err)
}
if resp.StatusCode != http.StatusOK {
respHeaders := extractResponseHeaders(resp.Header)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
defer resp.Body.Close()
cancel()
errBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("供应商返回错误: HTTP %d读取错误响应失败: %w", resp.StatusCode, readErr)
return nil, pkgErrors.ErrResponseRead.WithCause(readErr)
}
if len(errBody) > 0 {
return nil, fmt.Errorf("供应商返回错误: HTTP %d: %s", resp.StatusCode, string(errBody))
}
return nil, fmt.Errorf("供应商返回错误: HTTP %d", resp.StatusCode)
return &StreamResponse{
StatusCode: resp.StatusCode,
Headers: respHeaders,
Body: errBody,
}, nil
}
eventChan := make(chan StreamEvent, c.streamCfg.ChannelBufferSize)
go c.readStream(streamCtx, cancel, resp.Body, eventChan)
return eventChan, nil
return &StreamResponse{
StatusCode: resp.StatusCode,
Headers: respHeaders,
Events: eventChan,
}, nil
}
// readStream 读取 SSE 流
@@ -208,15 +223,17 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body
}
for {
idx := bytes.Index(dataBuf, []byte("\n\n"))
idx, sepLen := findSSEFrameSeparator(dataBuf)
if idx == -1 {
break
}
rawEvent := dataBuf[:idx]
dataBuf = dataBuf[idx+2:]
frameEnd := idx + sepLen
rawEvent := append([]byte(nil), dataBuf[:frameEnd]...)
dataBuf = dataBuf[frameEnd:]
if bytes.Contains(rawEvent, []byte("data: [DONE]")) {
if isSSEDoneFrame(rawEvent) {
eventChan <- StreamEvent{Data: rawEvent}
eventChan <- StreamEvent{Done: true}
return
}
@@ -225,11 +242,66 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body
}
if err == io.EOF {
if len(dataBuf) > 0 {
eventChan <- StreamEvent{Data: dataBuf}
}
return
}
}
}
func isSSEDoneFrame(frame []byte) bool {
payload, ok := sseFrameDataPayload(frame)
return ok && strings.TrimSpace(payload) == "[DONE]"
}
func sseFrameDataPayload(frame []byte) (string, bool) {
text := strings.TrimRight(string(frame), "\r\n")
lines := strings.Split(text, "\n")
var dataLines []string
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "data:") {
value := strings.TrimPrefix(line, "data:")
if strings.HasPrefix(value, " ") {
value = value[1:]
}
dataLines = append(dataLines, value)
}
}
if len(dataLines) == 0 {
return "", false
}
return strings.Join(dataLines, "\n"), true
}
func extractResponseHeaders(header http.Header) map[string]string {
respHeaders := make(map[string]string)
for k, vs := range header {
if len(vs) > 0 {
respHeaders[k] = vs[0]
}
}
return respHeaders
}
func findSSEFrameSeparator(data []byte) (int, int) {
lf := bytes.Index(data, []byte("\n\n"))
crlf := bytes.Index(data, []byte("\r\n\r\n"))
switch {
case lf < 0 && crlf < 0:
return -1, 0
case lf < 0:
return crlf, 4
case crlf < 0:
return lf, 2
case crlf <= lf:
return crlf, 4
default:
return lf, 2
}
}
// isNetworkError 判断是否为网络相关错误
func isNetworkError(err error) bool {
if err == nil {

View File

@@ -110,11 +110,13 @@ func TestClient_SendStream_CreatesChannel(t *testing.T) {
Body: []byte(`{}`),
}
eventChan, err := client.SendStream(context.Background(), spec)
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
require.NotNil(t, eventChan)
require.NotNil(t, streamResp)
require.Equal(t, http.StatusOK, streamResp.StatusCode)
require.NotNil(t, streamResp.Events)
for range eventChan {
for range streamResp.Events {
}
}
@@ -132,8 +134,10 @@ func TestClient_SendStream_ErrorResponse(t *testing.T) {
Body: []byte(`{}`),
}
_, err := client.SendStream(context.Background(), spec)
assert.Error(t, err)
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
require.NotNil(t, streamResp)
assert.Equal(t, http.StatusInternalServerError, streamResp.StatusCode)
}
func TestClient_SendStream_SSEEvents(t *testing.T) {
@@ -164,12 +168,13 @@ func TestClient_SendStream_SSEEvents(t *testing.T) {
Body: []byte(`{"model":"gpt-4","messages":[],"stream":true}`),
}
eventChan, err := client.SendStream(context.Background(), spec)
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
require.NotNil(t, streamResp)
var dataEvents [][]byte
var doneEvents int
for event := range eventChan {
for event := range streamResp.Events {
switch {
case event.Done:
doneEvents++
@@ -180,9 +185,56 @@ func TestClient_SendStream_SSEEvents(t *testing.T) {
}
}
assert.Equal(t, 2, len(dataEvents), "expected exactly 2 data events from SSE stream")
assert.Equal(t, 3, len(dataEvents), "expected 2 data frames plus DONE frame from SSE stream")
assert.Contains(t, string(dataEvents[0]), "Hello")
assert.Contains(t, string(dataEvents[1]), "World")
assert.Contains(t, string(dataEvents[2]), "[DONE]")
assert.Equal(t, 1, doneEvents)
assert.Contains(t, string(dataEvents[0]), "\n\n")
}
func TestClient_SendStream_DONEOnlyWhenDataPayloadEqualsDone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, ok := w.(http.Flusher)
require.True(t, ok)
_, err := w.Write([]byte("data: {\"text\":\"data: [DONE] is plain text\"}\n\n"))
require.NoError(t, err)
flusher.Flush()
_, err = w.Write([]byte("data: [DONE]\n\n"))
require.NoError(t, err)
flusher.Flush()
}))
defer server.Close()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
Body: []byte(`{}`),
}
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
require.NotNil(t, streamResp)
var dataEvents [][]byte
var doneEvents int
for event := range streamResp.Events {
switch {
case event.Done:
doneEvents++
case event.Error != nil:
t.Fatalf("unexpected error: %v", event.Error)
default:
dataEvents = append(dataEvents, event.Data)
}
}
require.Len(t, dataEvents, 2)
assert.Contains(t, string(dataEvents[0]), "plain text")
assert.Contains(t, string(dataEvents[1]), "[DONE]")
assert.Equal(t, 1, doneEvents)
}
@@ -203,13 +255,13 @@ func TestClient_SendStream_ContextCancellation(t *testing.T) {
Body: []byte(`{}`),
}
eventChan, err := client.SendStream(ctx, spec)
streamResp, err := client.SendStream(ctx, spec)
require.NoError(t, err)
cancel()
var gotError bool
for event := range eventChan {
for event := range streamResp.Events {
if event.Error != nil {
gotError = true
}
@@ -264,12 +316,12 @@ func TestClient_SendStream_SlowSSE(t *testing.T) {
Body: []byte(`{}`),
}
eventChan, err := client.SendStream(context.Background(), spec)
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
var dataCount int
var doneCount int
for event := range eventChan {
for event := range streamResp.Events {
switch {
case event.Done:
doneCount++
@@ -279,7 +331,7 @@ func TestClient_SendStream_SlowSSE(t *testing.T) {
dataCount++
}
}
assert.Equal(t, 1, dataCount, "expected exactly 1 data event from slow SSE")
assert.Equal(t, 2, dataCount, "expected 1 data frame plus DONE frame from slow SSE")
assert.Equal(t, 1, doneCount, "expected exactly 1 done event from slow SSE")
}
@@ -308,19 +360,19 @@ func TestClient_SendStream_SplitSSEEvents(t *testing.T) {
Body: []byte(`{}`),
}
eventChan, err := client.SendStream(context.Background(), spec)
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
var dataEvents int
var doneEvents int
for event := range eventChan {
for event := range streamResp.Events {
if event.Done {
doneEvents++
} else {
dataEvents++
}
}
assert.Equal(t, 2, dataEvents, "expected exactly 2 data events from split SSE")
assert.Equal(t, 3, dataEvents, "expected 2 data frames plus DONE frame from split SSE")
assert.Equal(t, 1, doneEvents)
}
@@ -397,11 +449,11 @@ func TestClient_SendStream_MidStreamNetworkError(t *testing.T) {
Body: []byte(`{}`),
}
eventChan, err := client.SendStream(context.Background(), spec)
streamResp, err := client.SendStream(context.Background(), spec)
require.NoError(t, err)
var gotData bool
for event := range eventChan {
for event := range streamResp.Events {
if event.Error != nil {
} else if !event.Done {
gotData = true

View File

@@ -0,0 +1,31 @@
package migrations
import (
"embed"
"fmt"
"io/fs"
"github.com/pressly/goose/v3"
)
//go:embed sqlite/*.sql mysql/*.sql
var FS embed.FS
func ForDriver(driver string) (goose.Dialect, fs.FS, error) {
switch driver {
case "sqlite":
subFS, err := fs.Sub(FS, "sqlite")
if err != nil {
return goose.DialectSQLite3, nil, fmt.Errorf("SQLite 迁移资源不可用: %w", err)
}
return goose.DialectSQLite3, subFS, nil
case "mysql":
subFS, err := fs.Sub(FS, "mysql")
if err != nil {
return goose.DialectMySQL, nil, fmt.Errorf("MySQL 迁移资源不可用: %w", err)
}
return goose.DialectMySQL, subFS, nil
default:
return goose.DialectSQLite3, nil, fmt.Errorf("不支持的数据库驱动: %s仅支持 sqlite 或 mysql", driver)
}
}

View File

@@ -0,0 +1,22 @@
package buildinfo
var (
version = "dev"
commit = "unknown"
buildTime = "unknown"
)
// Version 返回构建注入的版本号。
func Version() string {
return version
}
// Commit 返回构建注入的 git commit。
func Commit() string {
return commit
}
// BuildTime 返回构建注入的构建时间。
func BuildTime() string {
return buildTime
}

View File

@@ -0,0 +1,17 @@
package buildinfo
import "testing"
func TestDefaults(t *testing.T) {
if Version() == "" {
t.Fatal("Version() 不应为空")
}
if Commit() == "" {
t.Fatal("Commit() 不应为空")
}
if BuildTime() == "" {
t.Fatal("BuildTime() 不应为空")
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestLoadConfig_DefaultValues(t *testing.T) {
@@ -119,7 +120,7 @@ log:
assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override")
}
func TestLoadConfig_AutoCreate(t *testing.T) {
func TestLoadConfig_NoAutoCreate(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
@@ -131,25 +132,14 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
require.NotNil(t, cfg)
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values")
_, err = os.Stat(configPath)
assert.True(t, os.IsNotExist(err), "config file should not be auto-created")
}
func TestSaveAndLoadConfig(t *testing.T) {
tmpDir := t.TempDir()
homeDir, err := os.UserHomeDir()
require.NoError(t, err)
nexDir := filepath.Join(homeDir, ".nex")
configPath := filepath.Join(nexDir, "config.yaml")
originalConfig, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
require.NoError(t, err)
}
defer func() {
if originalConfig != nil {
require.NoError(t, os.WriteFile(configPath, originalConfig, 0o600))
}
}()
configPath := filepath.Join(tmpDir, "config.yaml")
cfg := &config.Config{
Server: config.ServerConfig{
@@ -176,10 +166,13 @@ func TestSaveAndLoadConfig(t *testing.T) {
},
}
err = config.SaveConfig(cfg)
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
loaded, err := config.LoadConfig()
err = os.WriteFile(configPath, data, 0o600)
require.NoError(t, err)
loaded, err := config.LoadConfigFromPath(configPath)
require.NoError(t, err)
assert.Equal(t, cfg.Server.Port, loaded.Server.Port)
@@ -194,3 +187,124 @@ func TestSaveAndLoadConfig(t *testing.T) {
assert.Equal(t, cfg.Log.MaxAge, loaded.Log.MaxAge)
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress)
}
func TestLoadDesktopConfig_FileOnly(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
yamlContent := `
server:
port: 8080
log:
level: debug
`
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
require.NoError(t, err)
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port)
assert.Equal(t, "debug", cfg.Log.Level)
}
func TestLoadDesktopConfig_IgnoresCLI(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
originalArgs := os.Args
defer func() { os.Args = originalArgs }()
os.Args = []string{"nex", "--server-port", "9999"}
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore CLI args and use config file")
}
func TestLoadDesktopConfig_IgnoresEnv(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
t.Setenv("NEX_SERVER_PORT", "9000")
t.Setenv("NEX_LOG_LEVEL", "debug")
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore env vars and use config file")
assert.Equal(t, "info", cfg.Log.Level, "desktop should ignore env vars and use default")
}
func TestLoadDesktopConfig_IgnoresUnknownArgs(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
originalArgs := os.Args
defer func() { os.Args = originalArgs }()
os.Args = []string{"nex", "--unknown-flag", "value", "--another-unknown"}
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err, "desktop should not fail on unknown CLI args")
assert.Equal(t, 8080, cfg.Server.Port)
}
func TestLoadDesktopConfig_Snapshot(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
require.NoError(t, err)
cfg, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port)
err = os.WriteFile(configPath, []byte("server:\n port: 9999\n"), 0o600)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Server.Port, "loaded config snapshot should not change when file changes")
cfg2, err := config.LoadDesktopConfigAtPath(configPath)
require.NoError(t, err)
assert.Equal(t, 9999, cfg2.Server.Port, "reload should pick up new config values")
}
func TestLoadDesktopConfig_InvalidFileFails(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "invalid yaml",
content: "server:\n port: [\n",
},
{
name: "validation failure",
content: "server:\n port: 70000\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.content), 0o600)
require.NoError(t, err)
_, err = config.LoadDesktopConfigAtPath(configPath)
require.Error(t, err, "desktop should not silently fall back to defaults for invalid config files")
})
}
}

View File

@@ -184,7 +184,7 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) {
body, _ := json.Marshal(openaiReq)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -299,7 +299,7 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -382,7 +382,7 @@ func TestConversion_OpenAIToAnthropic_Stream(t *testing.T) {
body, _ := json.Marshal(openaiReq)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -505,7 +505,7 @@ func TestConversion_ErrorResponse_Format(t *testing.T) {
// OpenAI 协议格式
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.True(t, w.Code >= 400)
@@ -521,15 +521,14 @@ func TestConversion_OldRoutes_Return404(t *testing.T) {
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"test"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
// Gin 路由匹配但协议不支持返回 400
assert.Equal(t, 400, w.Code)
assert.Equal(t, 404, w.Code)
// 旧 Anthropic 路由
w = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"test"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code)
assert.Equal(t, 404, w.Code)
}
// ============ Provider Protocol 字段测试 ============

View File

@@ -185,7 +185,7 @@ func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) {
},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -243,7 +243,7 @@ func TestE2E_OpenAI_NonStream_MultiTurn(t *testing.T) {
},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -300,7 +300,7 @@ func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) {
"tool_choice": "auto",
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -343,7 +343,7 @@ func TestE2E_OpenAI_NonStream_MaxTokens_Length(t *testing.T) {
"max_tokens": 30,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -379,7 +379,7 @@ func TestE2E_OpenAI_NonStream_UsageWithReasoning(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -420,7 +420,7 @@ func TestE2E_OpenAI_NonStream_Refusal(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "做坏事"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -463,7 +463,7 @@ func TestE2E_OpenAI_Stream_Text(t *testing.T) {
"stream": true,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -517,7 +517,7 @@ func TestE2E_OpenAI_Stream_ToolCalls(t *testing.T) {
"stream": true,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -556,7 +556,7 @@ func TestE2E_OpenAI_Stream_WithUsage(t *testing.T) {
"stream": true,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -980,7 +980,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_RequestFormat(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1063,7 +1063,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream(t *testing.T) {
"stream": true,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1140,7 +1140,7 @@ func TestE2E_OpenAI_ErrorResponse(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "test"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1225,7 +1225,7 @@ func TestE2E_OpenAI_NonStream_ParallelToolCalls(t *testing.T) {
"tool_choice": "auto",
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1266,7 +1266,7 @@ func TestE2E_OpenAI_NonStream_StopSequence(t *testing.T) {
"stop": []string{"5"},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1303,7 +1303,7 @@ func TestE2E_OpenAI_NonStream_ContentFilter(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "危险内容"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1586,7 +1586,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_NonStream_ToolCalls(t *testing.T) {
}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1657,7 +1657,7 @@ func TestE2E_CrossProtocol_StopReasonMapping(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "长文"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1707,7 +1707,7 @@ func TestE2E_OpenAI_NonStream_AssistantWithToolResult(t *testing.T) {
},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1759,7 +1759,7 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) {
"stream": true,
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1835,7 +1835,7 @@ func TestE2E_OpenAI_Upstream5xx_ErrorPassthrough(t *testing.T) {
"messages": []map[string]any{{"role": "user", "content": "test"}},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -1917,6 +1917,95 @@ func TestE2E_Anthropic_Stream_TruncatedSSE(t *testing.T) {
assert.Contains(t, respBody, "正常")
}
func TestE2E_OpenAI_Models_LocalAggregation(t *testing.T) {
r, upstream := setupE2ETest(t)
e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/openai/v1/models", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
data, ok := resp["data"].([]any)
require.True(t, ok)
require.Len(t, data, 1)
model := data[0].(map[string]any)
assert.Equal(t, "openai_p/gpt-4o", model["id"])
assert.Equal(t, "openai_p", model["owned_by"])
}
func TestE2E_OpenAI_Embeddings_SameProtocol(t *testing.T) {
r, upstream := setupE2ETest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
assert.Equal(t, "/embeddings", req.URL.Path)
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": []map[string]any{{
"index": 0,
"embedding": []float64{0.1, 0.2, 0.3},
}},
"model": "text-embedding-3-small",
"usage": map[string]any{
"prompt_tokens": 3,
"total_tokens": 3,
},
}))
})
e2eCreateProviderAndModel(t, r, "openai_p", "openai", "text-embedding-3-small", upstream.URL)
body, _ := json.Marshal(map[string]any{
"model": "openai_p/text-embedding-3-small",
"input": "hello",
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/v1/embeddings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "openai_p/text-embedding-3-small", resp["model"])
}
func TestE2E_OpenAI_Rerank_SameProtocol(t *testing.T) {
r, upstream := setupE2ETest(t)
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
assert.Equal(t, "/rerank", req.URL.Path)
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"index": 1,
"relevance_score": 0.98,
"document": "beta",
}},
"model": "rerank-v1",
}))
})
e2eCreateProviderAndModel(t, r, "openai_p", "openai", "rerank-v1", upstream.URL)
body, _ := json.Marshal(map[string]any{
"model": "openai_p/rerank-v1",
"query": "second",
"documents": []string{"alpha", "beta"},
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/openai/v1/rerank", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "openai_p/rerank-v1", resp["model"])
results, ok := resp["results"].([]any)
require.True(t, ok)
require.Len(t, results, 1)
}
var (
_ = fmt.Sprintf
_ = time.Now

View File

@@ -59,10 +59,10 @@ func (mr *MockProviderClientMockRecorder) Send(ctx, spec any) *gomock.Call {
}
// SendStream mocks base method.
func (m *MockProviderClient) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
func (m *MockProviderClient) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (*provider.StreamResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SendStream", ctx, spec)
ret0, _ := ret[0].(<-chan provider.StreamEvent)
ret0, _ := ret[0].(*provider.StreamResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}

View File

@@ -31,7 +31,7 @@
|------|------|
| **完整 HTTP 接口体系转换** | 覆盖 /models、/embeddings、/rerank 等全部接口的 URL 路由映射、请求头转换、请求体/响应体格式转换 |
| **输入输出解耦** | 客户端协议和服务端协议独立指定,任意组合 |
| **同协议透传** | client == provider 时跳过转换,零语义损失、零序列化开销 |
| **同协议透传** | client == provider 时跳过 Canonical 全量转换,保持协议语义 |
| **尽力转换** | 能对接的参数尽可能对接,不能对接的各自忽略,保障最大覆盖面 |
| **协议可扩展** | 添加新协议只需实现 Adapter不修改核心引擎 |
| **流式优先** | SSE 流式转换作为核心能力,与非流式同等地位 |
@@ -75,8 +75,8 @@
│ │ │ │
│ │ 入站: /{protocol}/{native_path} │ │
│ │ │ │
│ │ /<protocol_a>/v1/chat/completions → client=protocol_a, /v1/... │ │
│ │ /<protocol_b>/v1/messages → client=protocol_b, /v1/... │ │
│ │ /openai/v1/chat/completions → client=openai, /v1/chat/completions │ │
│ │ /anthropic/v1/messages → client=anthropic, /v1/messages │ │
│ │ │ │
│ │ Step 1: 识别 client protocolURL 前缀 / 配置映射 / 任意方式) │ │
│ │ Step 2: 剥离前缀 → 得到 nativePath │ │
@@ -112,17 +112,18 @@
### 2.2 URL 路由规则
调用方负责识别协议并剥离前缀,将 `nativePath``clientProtocol``providerProtocol` 传入引擎。调用方可自行决定识别方式URL 前缀、配置映射等)
调用方负责识别协议并剥离前缀,将 `nativePath``clientProtocol``providerProtocol` 传入引擎。网关只剥离第一段协议前缀,不对剩余路径做版本号归一化;`/v1` 是否存在属于协议原生路径,由对应 ProtocolAdapter 按本地 API reference 识别和映射
```
入站 URL 调用方剥离前缀后 引擎出站
──────────────────────────────────────────────────────────────────────────────
/<protocol_a>/v1/chat/completions → /v1/chat/completions → 目标协议路径
/<protocol_b>/v1/messages → /v1/messages目标协议路径
/<protocol_a>/v1/models → /v1/models → /v1/models通常不变
/openai/v1/chat/completions → /v1/chat/completions → /chat/completions
/openai/v1/models → /v1/models /models
/anthropic/v1/messages → /v1/messages → /v1/messages
/anthropic/v1/models → /v1/models → /v1/models
```
出站到上游 API 时使用服务端协议原生路径(无前缀)。
出站到上游 API 时使用服务端协议原生路径(无网关协议前缀),并由 `provider.BaseURL + providerAdapter.BuildUrl(nativePath, interfaceType)` 组合得到真实 URL。OpenAI `base_url` 配置到版本路径一级(如 `https://api.openai.com/v1`Anthropic `base_url` 配置到域名级(如 `https://api.anthropic.com`)。
### 2.3 请求处理流程
@@ -146,14 +147,14 @@
│ 响应处理 │ 原样返回 │ modelOverride非空时 │ Decode→modelOverride │
│ │ │ RewriteResponseModelName(body) │ →Encode │
│ │ │ │ │
│ 流式处理 │ chunk→[chunk] │ chunk→RewriteResponseModelName │ Decode→Middleware │
│ │ │ →[rewritten] │ →modelOverride→Encode │
│ 流式处理 │ raw SSE frame原样透传 │ SSE frame→仅改写data JSON中的model │ Decode→Middleware │
│ │ 保留[DONE]和frame边界 │ 解析失败则输出原frame继续处理 │ →modelOverride→Encode │
│ │ │ │ │
│ 性能开销 │ 最低 │ 低仅JSON字段改写 │ 高(完整序列化) │
└────────────────┴─────────────────────────┴────────────────────────────────────────┘
```
**智能透传的设计动机**:同协议场景下,若仅需改写 `model` 字段(如客户端请求模型 "X",上游需要模型 "Y"),无需完整解码/编码。直接在 JSON 层面手术式改写该字段,既保留原始请求的所有细节,又避免序列化开销
**智能透传的设计动机**:同协议场景下,若仅需改写 `model` 字段(如客户端请求模型 "X",上游需要模型 "Y"),无需进入 Canonical 全量解码/编码。直接在 JSON 层面改写该字段,保持未改写字段的 JSON 内容和类型不变;实现不承诺保留原始字节顺序、空白或对象字段顺序
#### 2.3.2 完整请求处理流程
@@ -162,7 +163,7 @@
┌──────────────┐ ┌──────────────┐
│ URL: │ 调用方完成: 1. 接口识别 │ URL: │
│ /<protocol>/ │ · clientProtocol 2. IsPassthrough? │ 目标协议 │
v1/... │ · nativePath ├─ yes ─┬─ 无 modelOverride → 透传车道 │ 原生路径 │
│ ... │ · nativePath ├─ yes ─┬─ 无 modelOverride → 透传车道 │ 原生路径 │
│ Headers: │ · providerProtocol │ └─ 有 modelOverride → 智能透传车道│ Headers: │
│ 协议原生格式 │ │ │ 目标协议格式 │
│ Body: │ └─ no → 完整转换车道 │ Body: │
@@ -174,7 +175,7 @@
**同协议透传**client == provider 时,仅重建 Header 后原样转发到上游。
**智能透传**:同协议且需改写 model 字段时,最小化 JSON 改写后转发。
**未知接口透传**无法识别的路径URL+Header 适配后 Body 原样转发。
**未知接口透传**无法识别的路径URL+Header 适配后 Body 原样转发;即使请求体存在顶层 `model`,也不做通用猜测
---
@@ -458,11 +459,13 @@ interface ProtocolAdapter {
**`buildHeaders` 的设计**Adapter 只需从 `provider` 中提取自己协议需要的认证和配置信息,构建自己的 Header 格式。不再需要理解其他协议的 Header。
**URL 事实来源**Adapter 的 `detectInterfaceType``buildUrl` 必须以本地 API reference 及网关对外协议契约为事实来源。OpenAI 对外路径为 `/openai/v1/...`,剥离协议前缀后的 nativePath 保留 `/v1`,例如 `/v1/chat/completions``/v1/models``/v1/embeddings`;但 OpenAI 供应商 `base_url` 配置到版本路径一级,`buildUrl` 输出上游 path 时移除 `/v1`,例如 `/chat/completions``/models``/embeddings`。Anthropic 参考 `docs/api_reference/anthropic`nativePath 与上游 path 均保留 `/v1`,例如 `/v1/messages``/v1/models`。不得根据其他协议是否包含 `/v1` 推断当前协议路径。
**智能透传方法的契约**
- `rewriteRequestModelName` / `rewriteResponseModelName` 必须**幂等**(多次调用结果相同)
- Rewrite 方法必须**最小化**(仅修改 model 字段,不触碰其他字段)
- Rewrite 失败时,引擎使用宽容策略:记录警告日志,使用原始 body 继续处理
- `extractModelName` 支持的接口类型CHAT、EMBEDDINGS、RERANK这些接口的请求体包含 model 字段)
- `extractModelName` 支持 adapter 明确适配的接口类型CHAT、EMBEDDINGS、RERANK这些接口的请求体包含 model 字段)。PASSTHROUGH 或未适配接口返回错误或空结果,调用方按无 model 请求透传,不做顶层 `model` 猜测。
### 5.3 InterfaceType
@@ -713,7 +716,7 @@ interface StreamConverter {
| 转换器 | 触发条件 | processChunk | flush |
|--------|---------|--------------|-------|
| `PassthroughStreamConverter` | 同协议 + 无 modelOverride | `[rawChunk]` | `[]` |
| `SmartPassthroughStreamConverter` | 同协议 + 有 modelOverride | `[rewriteResponseModelName(rawChunk)]` | `[]` |
| `SmartPassthroughStreamConverter` | 同协议 + 有 modelOverride | 按 SSE frame 改写 `data` JSON 中的 model失败输出原 frame | 输出缓存中的未完整 frame |
| `CanonicalStreamConverter` | 不同协议 | Decode→Middleware→modelOverride→Encode | decoder.flush()→encoder.flush() |
#### 6.3.3 PassthroughStreamConverter
@@ -732,16 +735,30 @@ class SmartPassthroughStreamConverter implements StreamConverter {
adapter: ProtocolAdapter
modelOverride: String
interfaceType: InterfaceType
buffer: ByteArray
processChunk(rawChunk): Array<RawSSEChunk> {
if rawChunk为空: return []
rewrittenChunk = adapter.rewriteResponseModelName(rawChunk, modelOverride, interfaceType)
if rewrite失败:
log.warn("智能透传改写失败,使用原始 chunk")
return [rawChunk]
return [rewrittenChunk]
buffer.append(rawChunk)
frames = splitCompleteSSEFrames(buffer)
result = []
for frame in frames:
payload = extractDataPayload(frame)
if payload == "[DONE]":
result.append(frame)
continue
rewrittenPayload = adapter.rewriteResponseModelName(payload, modelOverride, interfaceType)
if rewrite失败:
log.warn("智能透传改写失败,使用原始 SSE frame")
result.append(frame)
else:
result.append(rebuildSSEFrameWithData(frame, rewrittenPayload))
return result
}
flush(): Array<RawSSEChunk> {
if buffer为空: return []
return [buffer.drain()] // 未完整 frame 原样输出
}
flush(): Array<RawSSEChunk> { return [] }
}
```
@@ -794,7 +811,9 @@ function createStreamConverter(clientProtocol, providerProtocol, modelOverride,
if isPassthrough(clientProtocol, providerProtocol):
if modelOverride非空:
adapter = registry.get(clientProtocol)
// 解析 SSE frame仅改写 data JSON 中的 model解析失败输出原 frame
return new SmartPassthroughStreamConverter(adapter, modelOverride, interfaceType)
// raw passthrough 保留 SSE frame 边界和 [DONE]
return new PassthroughStreamConverter()
providerAdapter = registry.get(providerProtocol)
@@ -881,7 +900,7 @@ converter.flush()
// 场景6: 同协议流式智能透传
converter = engine.createStreamConverter("openai", "openai", "gpt-4-turbo", CHAT)
// 使用 SmartPassthroughStreamConverter逐 chunk 改写 model 字段
// 使用 SmartPassthroughStreamConverter按 SSE frame 改写 data JSON 中的 model 字段
```
---
@@ -894,10 +913,10 @@ converter = engine.createStreamConverter("openai", "openai", "gpt-4-turbo", CHAT
上游 SSE 流
├─ 同协议 + 无 modelOverride: PassthroughStreamConverter
chunk → [chunk]
raw SSE frame → [raw SSE frame]
├─ 同协议 + 有 modelOverride: SmartPassthroughStreamConverter
chunk → [rewriteResponseModelName(chunk)]
SSE frame → data JSON model rewrite → [SSE frame]
└─ 不同协议: CanonicalStreamConverter
StreamDecoder StreamEncoder
@@ -1025,7 +1044,8 @@ ErrorCode = Enum<
UTF8_DECODE_ERROR, // UTF-8 解码错误
PROTOCOL_CONSTRAINT_VIOLATION, // 违反协议约束
ENCODING_FAILURE, // 编码失败
INTERFACE_NOT_SUPPORTED // 目标协议不支持此接口
INTERFACE_NOT_SUPPORTED, // 目标协议不支持此接口
UNSUPPORTED_MULTIMODAL // 多模态内容块暂不支持跨协议转换
>
```
@@ -1050,7 +1070,7 @@ ErrorCode = Enum<
│ │ - interceptStreamEvent 返回 error → continue │
├─────────────────┼───────────────────────────────────────────────────┤
│ 智能透传 │ 宽容模式:重写失败则使用原始 body/chunk │
│ │ - Rewrite 失败 → log.warn + 返回原始 body/chunk
│ │ - Rewrite 失败 → log.warn + 返回原始 body/SSE frame
├─────────────────┼───────────────────────────────────────────────────┤
│ 请求中间件 │ 严格模式:返回错误则中断整个转换 │
│ │ - intercept 返回 error → 返回 error │
@@ -1072,11 +1092,24 @@ ErrorCode = Enum<
具体策略由 `supportsInterface` 返回值决定:返回 false 时引擎直接透传 body。
**多模态处理**`UNSUPPORTED_MULTIMODAL`Canonical Model 保留 image、audio、video、file 内容块占位,但当前跨协议多模态编解码暂未实现。跨协议完整转换遇到这些内容块时返回 `UNSUPPORTED_MULTIMODAL`;同协议 raw passthrough 和 smart passthrough 不拒绝多模态字段,仍按原协议请求透传或仅改写 model。
### 9.3 错误响应格式
转换失败时,错误响应用**客户端协议client protocol**的格式编码。由 `clientAdapter.encodeError(error)` 完成。各协议的错误响应 JSON 结构和 HTTP 状态码映射详见各自的协议适配文档(附录 E
ConversionEngine 的 `encodeError` 是协议 Adapter 的错误编码能力,用于 SDK 内部或非代理场景把 `ConversionError` 编码为客户端协议格式。各协议的错误响应 JSON 结构和 HTTP 状态码映射详见各自的协议适配文档(附录 E
Middleware 中断转换时同理,引擎调用 clientAdapter.encodeError 将 ConversionError 编码为客户端可理解的格式。
ProxyHandler 对外遵循更明确的代理错误边界:
| 场景 | 响应策略 |
|------|---------|
| 网关层错误 | 返回应用统一 JSON`{"error": "...", "code": "..."}` |
| 上游已返回 HTTP 非 2xx | 透传上游 status、过滤 hop-by-hop 后的 headers、body |
| 未收到上游 HTTP 响应 | 返回 `UPSTREAM_UNAVAILABLE`HTTP 502 |
| 网关层 JSON 解析失败 | 返回 `INVALID_JSON`HTTP 400 |
| 路由统一模型失败 | 返回 `MODEL_NOT_FOUND`HTTP 404 |
| 跨协议转换失败 | 返回 `CONVERSION_FAILED` 或具体转换错误码 |
因此,代理接口中的网关层错误不会再编码成 OpenAI/Anthropic 协议错误格式;只有上游已经返回的错误响应才按上游原样透传。
#### 9.3.1 EncodeError Fallback 行为
@@ -1179,7 +1212,7 @@ ProtocolAdapter
// ─── 流式处理 ───
StreamConverter: .processChunk(raw) / .flush()
├─ PassthroughStreamConverter [raw] → [raw]
├─ SmartPassthroughStreamConverter [raw] → [rewrite(raw)]
├─ SmartPassthroughStreamConverter [SSE frame] → [rewrite(data JSON)]
└─ CanonicalStreamConverter decode → middleware → modelOverride → encode
// ─── 中间件 ───
@@ -1248,7 +1281,7 @@ Canonical Model 是**活的公共契约**,不是固定不变的。其字段集
- `decodeXxxRequest` / `encodeXxxRequest`:扩展层接口仅在 `supportsInterface` 返回 true 时被调用§6.2 convertBody 分支);返回 false 时引擎直接透传 body
- `createStreamDecoder` / `createStreamEncoder`:引擎在 `createStreamConverter` 中调用§6.1Decoder 来自 provider 协议(解码上游 SSEEncoder 来自 client 协议(编码给客户端)
- `buildHeaders`:每次请求出站时调用,同协议透传也会调用
- `encodeError`:转换失败或 Middleware 中断时调用,使用 client 协议格式编码错误响应
- `encodeError`:转换失败或 Middleware 中断时的协议错误编码能力ProxyHandler 的网关层错误使用应用统一格式,不调用协议错误编码
### D.1 协议基本信息

View File

@@ -24,7 +24,7 @@
| -------- | ----------------------------------- |
| 协议名称 | `"openai"` |
| 协议版本 | 无固定版本头API 持续演进 |
| Base URL | `https://api.openai.com` |
| Base URL | `https://api.openai.com/v1`(供应商配置到版本路径一级) |
| 认证方式 | `Authorization: Bearer <api_key>` |
---
@@ -47,13 +47,13 @@
OpenAI.detectInterfaceType(nativePath):
if nativePath == "/v1/chat/completions": return CHAT
if nativePath == "/v1/models": return MODELS
if nativePath matches "^/v1/models/[^/]+$": return MODEL_INFO
if nativePath startsWith "/v1/models/" and suffix is not empty: return MODEL_INFO
if nativePath == "/v1/embeddings": return EMBEDDINGS
if nativePath == "/v1/rerank": return RERANK
return PASSTHROUGH
```
**说明**`detectInterfaceType` 由 OpenAI Adapter 实现,根据 OpenAI 协议的 URL 路径约定识别接口类型。
**说明**`detectInterfaceType` 由 OpenAI Adapter 实现,根据 OpenAI 协议的 URL 路径约定识别接口类型。网关剥离 `/openai` 协议前缀后OpenAI Adapter 接收的 nativePath 保留 `/v1`
### 2.3 接口能力矩阵
@@ -74,14 +74,16 @@ OpenAI.supportsInterface(type):
```
OpenAI.buildUrl(nativePath, interfaceType):
switch interfaceType:
case CHAT: return "/v1/chat/completions"
case MODELS: return "/v1/models"
case MODEL_INFO: return "/v1/models/{modelId}"
case EMBEDDINGS: return "/v1/embeddings"
case RERANK: return "/v1/rerank"
case CHAT: return "/chat/completions"
case MODELS: return "/models"
case MODEL_INFO: return "/models/{modelId}"
case EMBEDDINGS: return "/embeddings"
case RERANK: return "/rerank"
default: return nativePath
```
**说明**OpenAI 供应商 `base_url` 配置到版本路径一级,`buildUrl` 输出上游 path 时移除 nativePath 中的 `/v1`,避免拼接出重复版本段。
---
## 3. 请求头构建

View File

@@ -1 +1,2 @@
VITE_API_BASE=
VITE_APP_VERSION=0.1.7

View File

@@ -1 +1,2 @@
VITE_API_BASE=
VITE_APP_VERSION=0.1.7

View File

@@ -1 +1,2 @@
VITE_API_BASE=/api
VITE_APP_VERSION=0.1.7

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

View File

@@ -1,7 +0,0 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

View File

@@ -1,6 +1,6 @@
# AI Gateway Frontend
# Nex Frontend
AI 网关管理前端,提供供应商配置和用量统计界面。
AI 网关管理前端,提供供应商配置和总览界面。
## 技术栈
@@ -11,7 +11,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
- **UI 组件库**: TDesign
- **路由**: React Router v7
- **数据获取**: TanStack Query v5
- **样式**: SCSS Modules禁止使用纯 CSS
- **样式**: TDesign 组件 props 优先TDesign tokens 次之SCSS 作为兜底补充
- **测试**: Vitest + React Testing Library + Playwright
- **代码格式化**: Prettier
@@ -86,17 +86,20 @@ frontend/
│ │ ├── client.ts # 统一 request<T>() + 字段转换
│ │ ├── providers.ts # Provider CRUD
│ │ ├── models.ts # Model CRUD
│ │ ── stats.ts # Stats 查询
│ │ ── stats.ts # Stats 查询
│ │ └── version.ts # 后端版本查询
│ ├── components/
│ │ └── AppLayout/ # 侧边栏导航布局
│ ├── hooks/ # TanStack Query hooks
│ │ ├── useProviders.ts
│ │ ├── useModels.ts
│ │ ── useStats.ts
│ │ ── useStats.ts
│ │ └── useVersion.ts
│ ├── pages/
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
│ │ ├── Stats/ # 用量统计
│ │ ├── Stats/ # 总览
│ │ ├── Settings/ # 设置(开发中)
│ │ ├── About/ # 关于页面(品牌与版本信息)
│ │ └── NotFound.tsx
│ ├── routes/
│ │ └── index.tsx # 路由配置
@@ -111,6 +114,7 @@ frontend/
│ ├── main.tsx
│ └── index.scss
├── e2e/ # Playwright E2E 测试
├── public/ # 静态资源icon.png 来源于 ../assets/icon.png
├── vitest.config.ts
├── playwright.config.ts
├── tsconfig.json
@@ -145,7 +149,8 @@ bun run build
```bash
bun run lint # ESLint 检查
bun run format:check # Prettier 格式检查
bun run check # 同时检查 lint 和格式
bun run typecheck # TypeScript 类型检查
bun run check # 同时检查类型、lint 和格式
```
### 代码格式化
@@ -184,22 +189,29 @@ bun run test:e2e
- API Key 脱敏显示
- 启用/禁用状态标签
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
- **一键复制**Base URL 和 API Key 支持一键复制到剪贴板
### 模型管理
- 展开供应商行查看关联模型
- 添加/编辑/删除模型
- 按供应商筛选模型
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
- **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID
### 用量统计
### 总览
- 查看统计数据
- 按供应商筛选
- 按模型筛选
- 按日期范围筛选DatePicker.RangePicker
### 关于页面
- 展示应用名称、产品描述和项目链接
- 展示前端版本、后端版本、后端 commit 和构建时间
- 根据 `VITE_APP_VERSION``GET /api/version` 返回值提示前后端版本是否一致
## 测试策略
### 目录结构
@@ -231,9 +243,10 @@ __tests__/
## 环境变量
| 变量 | 开发环境 | 生产环境 | 说明 |
| --------------- | -------- | -------- | ------------------------------- |
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
| 变量 | 开发环境 | 生产环境 | 说明 |
| ------------------ | -------- | -------- | ----------------------------------------- |
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
| `VITE_APP_VERSION` | `0.1.0` | `0.1.0` | 前端构建版本,由 `make version-sync` 同步 |
**E2E 测试特有**
@@ -242,9 +255,11 @@ __tests__/
## 开发规范
- 所有样式使用 SCSS禁止使用纯 CSS 文件
- 组件级样式使用 SCSS Modules\*.module.scss
- 样式优先使用 TDesign 组件 props`hoverShadow``headerBordered``variant``shape``gutter`
- 组件 props 无法表达时使用 TDesign tokens`var(--td-*)`
- 仅当 props 和 tokens 无法满足布局、响应式或品牌视觉需求时使用 SCSS禁止使用纯 CSS 文件
- 图标优先使用 TDesign 图标tdesign-icons-react
- 应用 favicon 使用 `frontend/public/icon.png`,该文件来源于仓库根目录 `assets/icon.png`
- TypeScript strict 模式,禁止 any 类型
- API 层自动处理 snake_case ↔ camelCase 字段转换
- 使用路径别名 `@/` 引用 src 目录

View File

@@ -12,13 +12,13 @@ test.describe('侧边栏', () => {
})
test('应显示应用名称', async ({ page }) => {
await expect(page.locator('aside').getByText('AI Gateway')).toBeVisible()
await expect(page.locator('aside').getByText('Nex')).toBeVisible()
})
test('应显示导航菜单项', async ({ page }) => {
const aside = page.locator('aside')
await expect(aside.getByText('供应商管理')).toBeVisible()
await expect(aside.getByText('用量统计')).toBeVisible()
await expect(aside.getByText('总览')).toBeVisible()
})
})
@@ -28,24 +28,33 @@ test.describe('页面导航', () => {
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
})
test('应能切换到用量统计', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
test('应能切换到总览', async ({ page }) => {
await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
test('应能切换回供应商管理', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
await page.locator('aside').getByText('供应商管理').click()
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
})
test('应能切换到关于页面并显示版本信息', async ({ page }) => {
await page.locator('aside').getByText('关于').click()
await expect(page.getByRole('heading', { name: '关于' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Nex' })).toBeVisible()
await expect(page.getByText('前端版本')).toBeVisible()
await expect(page.getByText('后端版本')).toBeVisible()
})
test('应在刷新后保持当前页面', async ({ page }) => {
await page.locator('aside').getByText('用量统计').click()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await page.locator('aside').getByText('总览').click()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
await page.reload()
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
})

View File

@@ -41,7 +41,7 @@ test.describe('统计概览', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/stats')
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
test('应显示正确的总请求量', async ({ page }) => {
@@ -99,7 +99,7 @@ test.describe('统计筛选', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/stats')
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
})
test('按供应商筛选', async ({ page }) => {

View File

@@ -2,9 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Gateway</title>
<title>Nex</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,16 +1,18 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "0.1.7",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && bun run check && vite build",
"build": "bun run check && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "bun run lint && bun run format:check",
"typecheck": "tsc -b",
"check": "bun run typecheck && bun run lint && bun run format:check",
"fix": "bun run lint:fix && bun run format",
"preview": "vite preview",
"test": "vitest run",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

BIN
frontend/public/icon.png LFS Normal file

Binary file not shown.

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

Some files were not shown because too many files have changed in this diff Show More