1
0

26 Commits

Author SHA1 Message Date
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
135 changed files with 6950 additions and 1075 deletions

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

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

@@ -0,0 +1,314 @@
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"
build-web:
name: Build Web Asset
needs: prepare
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
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
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
msystem: MINGW64
packages: >-
make
mingw-w64-x86_64-gcc
- arch: arm64
runner: windows-11-arm
msystem: CLANGARM64
packages: >-
make
mingw-w64-clang-aarch64-clang
mingw-w64-clang-aarch64-llvm
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}
run: |
set -euo pipefail
command -v go
go version
command -v bun
bun --version
command -v make
make --version
if [ "${{ matrix.arch }}" = "arm64" ]; then
command -v clang
clang --version
if command -v llvm-windres >/dev/null 2>&1; then
llvm-windres --version
else
command -v windres
windres --version
fi
else
command -v gcc
gcc --version
command -v windres
windres --version
fi
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}
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
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/*

4
.gitignore vendored
View File

@@ -408,7 +408,9 @@ temp
skills-lock.json
.worktrees
!scripts/build/
backend/bin
# 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.

574
Makefile
View File

@@ -1,253 +1,409 @@
.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 \
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 \
_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
WINDOWS_WINDRES_FORMAT := pe-aarch64
WINDOWS_RESOURCE := rsrc_windows_arm64.syso
else
APPIMAGE_ARCH := x86_64
DEB_ARCH := amd64
RPM_ARCH := x86_64
WINDOWS_WINDRES_FORMAT := pe-x86-64
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
endif
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'
# ============================================
# 后端
# 版本管理
# ============================================
backend-build:
cd backend && go build -o bin/server ./cmd/server
version-sync:
go run ./versionctl sync
backend-run:
cd backend && go run ./cmd/server
version-check:
go run ./versionctl check
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
version-bump: BUMP ?= patch
version-bump:
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP)))
$(eval _NEW_VERSION := $(shell 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)"
# ============================================
# MySQL 专项测试
# Server 模式
# ============================================
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; \
fi; \
echo "Waiting... ($$i/30)"; \
sleep 1; \
done; \
echo "MySQL failed to start"; \
exit 1
server-run:
@$(MAKE) -j2 _server-run-backend _server-run-frontend
test-mysql-down:
@echo "Stopping MySQL test container..."
cd backend/tests/mysql && docker-compose down -v
server-build: version-check _backend-build _frontend-build
@printf 'Server build complete\n'
test-mysql: test-mysql-up
@echo "Running MySQL tests..."
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
$(MAKE) test-mysql-down
server-lint: _backend-lint _frontend-check
@printf 'Server lint complete\n'
test-mysql-quick:
@echo "Running MySQL tests (without container management)..."
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
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 模式
# ============================================
frontend-install:
cd frontend && bun install
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'
@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'
frontend-build: frontend-install
cd frontend && bun run build
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'
frontend-dev: frontend-install
cd frontend && bun dev
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'
frontend-test: frontend-install
cd frontend && bun run test
desktop-lint: _backend-lint _frontend-check
@printf 'Desktop lint complete\n'
frontend-test-watch: frontend-install
cd frontend && bun run test:watch
desktop-test: _desktop-test
@printf 'Desktop tests passed\n'
frontend-test-coverage: frontend-install
cd frontend && bun run test:coverage
desktop-clean: _desktop-clean
@printf 'Desktop artifacts cleaned\n'
frontend-test-e2e: frontend-install
cd frontend && bun run test:e2e
_desktop-test:
cd backend && go test ./cmd/desktop/... -v
frontend-lint: frontend-install
cd frontend && bun run lint
_desktop-clean:
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso backend/cmd/desktop/rsrc_windows_arm64.syso
frontend-clean:
rm -rf frontend/dist frontend/.next frontend/node_modules frontend/coverage frontend/playwright-report frontend/test-results frontend/tsconfig.tsbuildinfo
# ============================================
# 桌面应用
# ============================================
desktop-build: desktop-build-mac desktop-build-win desktop-build-linux
@echo "✅ Desktop builds complete for all platforms"
desktop-prepare-frontend:
@echo "📦 Preparing frontend for desktop..."
_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/; \
_desktop-prepare-windows-resource: _check-windows-target-arch
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
@if [ "$(TARGET_ARCH)" = "arm64" ] && [ "$(WINDRES)" = "windres" ] && command -v llvm-windres >/dev/null 2>&1; then \
WINDRES_CMD=llvm-windres; \
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; \
WINDRES_CMD="$(WINDRES)"; \
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 $(WINDOWS_WINDRES_FORMAT) -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
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
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" ] && [ "$(TARGET_ARCH)" != "arm64" ]; 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
# ============================================
# 共享 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

217
README.md
View File

@@ -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` 配置
@@ -91,7 +93,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
## 快速开始
@@ -107,15 +109,18 @@ make desktop-build-mac
# Windows
make desktop-build-win
# Windows arm64
make desktop-build-win TARGET_ARCH=arm64
# 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.exe / nex-win-arm64.exeLinux: nex-linux-amd64 / nex-linux-arm64
- 系统托盘图标出现,浏览器自动打开管理界面
- 点击托盘图标显示菜单,可打开管理界面或退出
- 关闭浏览器后服务继续运行,可通过托盘重新打开
@@ -123,8 +128,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-utilsAppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链desktop 使用 GUI linker flags 隐藏控制台窗口
- macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示
**Linux 桌面环境兼容性**
- GNOME: 需要 AppIndicator 扩展
@@ -132,50 +139,88 @@ 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` 启动。首次启动会自动:
`make server-run` 会并行启动:
- 后端服务:`http://localhost:9826`
- 前端开发服务器:`http://localhost:5173`
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
- 创建配置文件 `~/.nex/config.yaml`
- 初始化数据库 `~/.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` |
| Windows arm64 | `nex-server_<version>_windows_arm64.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` |
| Windows arm64 | `nex-desktop_<version>_windows_arm64.zip` |
Linux deb 包声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行依赖rpm 包声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
## 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,6 +243,9 @@ bun dev
查询参数支持:`provider_id``model_name``start_date``end_date``group_by`
#### 版本信息
- `GET /api/version` - 获取后端构建版本信息(`version``commit``build_time`),用于前端 About 页面诊断前后端版本一致性
## 配置
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
@@ -271,53 +319,100 @@ 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 dev # 启动开发环境(并行启动后端和前端)
make build # 构建所有产物
make lint # 检查所有代码
make clean # 清理所有构建产物
# 全局命令
make lint # 前后端共享检查
make test # 默认全量测试(不含 MySQL/E2E
make clean # 清理所有构建产物和测试报告
# 后端开发
make backend-build # 构建后端
make backend-run # 运行后端
make backend-dev # 后端开发模式
make backend-lint # 后端代码检查
make backend-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 模式产物
# 数据库操作
make backend-db-up # 数据库迁移
make backend-db-down # 数据库回滚
make backend-db-status # 数据库迁移状态
make backend-db-create # 创建新迁移
# 前端开发
make frontend-build # 构建前端
make frontend-dev # 前端开发模式
make frontend-lint # 前端代码检查
make frontend-clean # 清理前端构建产物
# 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 产物
```
## 版本与发布
### 统一版本源
- 仓库根目录 `VERSION` 是全仓唯一版本源,格式固定为 `x.y.z`
- `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION` 由仓库工具同步,不能手工漂移
### 本地版本演进
```bash
# 递增版本(自动 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` 会自动执行发布流水线
- 三个平台 job 会在正式构建前先检查 `go``bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 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 +421,4 @@ make frontend-clean # 清理前端构建产物
## 许可证
MIT
Apache License 2.0

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.4

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: 130 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"),
@@ -220,7 +220,7 @@ OpenAI Response ← Canonical Response ← Anthropic Response
### Smart Passthrough 机制
同协议请求走 Smart Passthrough 路径,**零序列化开销**
同协议请求走 Smart Passthrough 路径,不进入 Canonical 全量转换
```
1. 检测 clientProtocol == providerProtocol
@@ -229,12 +229,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 +303,7 @@ StreamConverter (接口)
| `PROTOCOL_CONSTRAINT_VIOLATION` | 协议约束违反 |
| `ENCODING_FAILURE` | 编码失败 |
| `INTERFACE_NOT_SUPPORTED` | 接口不支持(如 Anthropic Embeddings |
| `UNSUPPORTED_MULTIMODAL` | 跨协议暂不支持多模态内容 |
### AppError 预定义错误
@@ -434,24 +437,37 @@ 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
```
## 数据库迁移
```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 +476,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 +494,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 +535,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 +577,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 +598,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

@@ -18,14 +18,6 @@ func showError(title, message string) {
}
}
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 escapeAppleScript(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")

View File

@@ -65,21 +65,3 @@ func showError(title, message string) {
dialogLogger().Error("无法显示错误对话框")
}
}
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)
}
}

View File

@@ -3,35 +3,60 @@
package main
import (
"errors"
"fmt"
"syscall"
"unsafe"
"go.uber.org/zap"
)
const (
MB_ICONERROR = 0x10
MB_ICONINFORMATION = 0x40
mbIconError = 0x10
mbIconInformation = 0x40
)
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)
if err := messageBox(title, message, mbIconError); err != nil {
if zapLogger != nil {
zapLogger.Warn("显示错误对话框失败", zap.Error(err))
}
}
}
func showAbout() {
messageBox(appAboutTitle, aboutMessage(), MB_ICONINFORMATION)
}
func messageBox(title, message string, flags uint) error {
titlePtr, err := syscall.UTF16PtrFromString(title)
if err != nil {
return err
}
func messageBox(title, message string, flags uint) {
titlePtr, _ := syscall.UTF16PtrFromString(title)
messagePtr, _ := syscall.UTF16PtrFromString(message)
procMessageBoxW.Call(
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

@@ -25,6 +25,7 @@ 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"
@@ -129,6 +130,7 @@ func main() {
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -138,7 +140,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)
setupStaticFiles(r)
server = &http.Server{
@@ -151,7 +153,11 @@ func main() {
shutdownCtx, shutdownCancel = context.WithCancel(context.Background())
go func() {
zapLogger.Info("AI Gateway 启动", zap.String("addr", server.Addr))
zapLogger.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.ListenAndServe(); err != nil && err != http.ErrServerClosed {
zapLogger.Fatal("服务器启动失败", zap.Error(err))
}
@@ -167,8 +173,10 @@ func main() {
setupSystray(port)
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
r.Any("/v1/*path", proxyHandler.HandleProxy)
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler) {
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")
{
@@ -199,12 +207,26 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
})
}
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) {
distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
distFS, err := frontendDistFS()
if err != nil {
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
}
setupStaticFilesWithFS(r, distFS)
}
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 +259,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
@@ -287,8 +312,6 @@ func setupSystray(port int) {
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
mPort.Disable()
systray.AddSeparator()
mAbout := systray.AddMenuItem("关于", "")
systray.AddSeparator()
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
go func() {
@@ -298,8 +321,6 @@ func setupSystray(port int) {
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()

View File

@@ -3,17 +3,59 @@
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) {
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
return 0, syscall.Errno(5)
})
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("showError 不应因 MessageBoxW 失败而 panic: %v", recovered)
}
}()
showError("测试错误", "这是一条测试错误消息")
}
func TestShowAbout_WindowsBranch(t *testing.T) {
showAbout()
}

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

@@ -22,7 +22,7 @@ func TestCheckPortAvailable(t *testing.T) {
func TestCheckPortOccupied(t *testing.T) {
port := 19827
listener, err := net.Listen("tcp", ":19827")
listener, err := net.Listen("tcp", ":19827") //nolint:gosec // 需要验证 checkPortAvailable 对通配地址占用的检测行为
if err != nil {
t.Fatalf("无法启动测试服务器: %v", err)
}

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())
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

@@ -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,60 +13,14 @@ import (
func TestSetupStaticFiles(t *testing.T) {
gin.SetMode(gin.TestMode)
distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
distFS, err := frontendDistFS()
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, distFS)
t.Run("API 404", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/test", nil)
@@ -79,6 +32,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()
@@ -121,3 +100,139 @@ func TestSetupStaticFiles(t *testing.T) {
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)
distFS, err := frontendDistFS()
if err != nil {
t.Skipf("跳过测试: 前端资源未构建: %v", err)
return
}
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, distFS)
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 {
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
}
return
}
if w.Code != http.StatusNotFound {
t.Errorf("期望静态资源返回 200 或 404, 实际 %d", w.Code)
}
})
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)
}
})
}

View File

@@ -22,6 +22,7 @@ import (
"nex/backend/internal/provider"
"nex/backend/internal/repository"
"nex/backend/internal/service"
"nex/backend/pkg/buildinfo"
pkgLogger "nex/backend/pkg/logger"
)
@@ -92,6 +93,7 @@ func main() {
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
versionHandler := handler.NewVersionHandler()
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -101,7 +103,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)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
@@ -111,7 +113,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 +141,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) {
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
r.GET("/api/version", versionHandler.GetVersion)
providers := r.Group("/api/providers")
{

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())
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

@@ -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

@@ -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,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,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) {
@@ -135,21 +136,7 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
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 +163,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)

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.4

View File

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

View File

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

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,4 +1,4 @@
# AI Gateway Frontend
# Nex Frontend
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/ # 用量统计
│ │ ├── 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
@@ -200,6 +204,12 @@ bun run test:e2e
- 按模型筛选
- 按日期范围筛选DatePicker.RangePicker
### 关于页面
- 展示应用名称、产品描述和项目链接
- 展示前端版本、后端版本、后端 commit 和构建时间
- 根据 `VITE_APP_VERSION``GET /api/version` 返回值提示前后端版本是否一致
## 测试策略
### 目录结构
@@ -231,9 +241,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 +253,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,7 +12,7 @@ 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 }) => {
@@ -41,6 +41,15 @@ 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()
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()

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,7 +1,8 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "0.1.4",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",

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

View File

@@ -0,0 +1,30 @@
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import { getBackendVersion } from '@/api/version'
describe('version API', () => {
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('fetches backend version and converts build_time to buildTime', async () => {
server.use(
http.get('http://localhost:3000/api/version', () => {
return HttpResponse.json({
version: '0.1.0',
commit: 'abc1234',
build_time: '2026-05-05T00:00:00Z',
})
})
)
await expect(getBackendVersion()).resolves.toEqual({
version: '0.1.0',
commit: 'abc1234',
buildTime: '2026-05-05T00:00:00Z',
})
})
})

View File

@@ -1,25 +1,37 @@
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router'
import { describe, it, expect } from 'vitest'
import { AppLayout } from '@/components/AppLayout'
const renderWithRouter = (component: React.ReactNode) => {
return render(<BrowserRouter>{component}</BrowserRouter>)
return render(<MemoryRouter initialEntries={['/providers']}>{component}</MemoryRouter>)
}
describe('AppLayout', () => {
it('renders sidebar with app name', () => {
renderWithRouter(<AppLayout />)
const appNames = screen.getAllByText('AI Gateway')
expect(appNames.length).toBeGreaterThan(0)
expect(screen.getByText('Nex')).toBeInTheDocument()
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
})
it('keeps logo visible when sidebar is collapsed', async () => {
const user = userEvent.setup()
renderWithRouter(<AppLayout />)
await user.click(screen.getByLabelText('收起侧边栏'))
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
expect(screen.queryByText('Nex')).not.toBeInTheDocument()
expect(screen.getByLabelText('展开侧边栏')).toBeInTheDocument()
})
it('renders navigation menu items', () => {
renderWithRouter(<AppLayout />)
expect(screen.getByText('供应商管理')).toBeInTheDocument()
expect(screen.getByText('用量统计')).toBeInTheDocument()
expect(screen.getAllByText('供应商管理').length).toBeGreaterThan(0)
expect(screen.getAllByText('用量统计').length).toBeGreaterThan(0)
})
it('renders settings menu item', () => {
@@ -28,6 +40,12 @@ describe('AppLayout', () => {
expect(screen.getByText('设置')).toBeInTheDocument()
})
it('renders about menu item', () => {
renderWithRouter(<AppLayout />)
expect(screen.getByText('关于')).toBeInTheDocument()
})
it('renders content outlet', () => {
const { container } = renderWithRouter(<AppLayout />)

View File

@@ -0,0 +1,33 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { useBackendVersion } from '@/hooks/useVersion'
const server = setupServer(
http.get('/api/version', () => {
return HttpResponse.json({ version: '0.1.0', commit: 'abc1234', build_time: '2026-05-05T00:00:00Z' })
})
)
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('useBackendVersion', () => {
it('fetches backend version', async () => {
const { result } = renderHook(() => useBackendVersion(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({ version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' })
})
})

View File

@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBackendVersion } from '@/hooks/useVersion'
import AboutPage from '@/pages/About'
vi.mock('@/hooks/useVersion', () => ({
useBackendVersion: vi.fn(),
}))
vi.mock('@/constants/app', () => ({
APP_NAME: 'Nex',
APP_DESCRIPTION: 'AI Gateway - 统一的大模型 API 网关',
APP_WEBSITE: 'https://github.com/nex/gateway',
APP_VERSION: '0.1.0',
}))
const mockUseBackendVersion = useBackendVersion as ReturnType<typeof vi.fn>
describe('AboutPage', () => {
beforeEach(() => {
mockUseBackendVersion.mockReturnValue({
data: { version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
isError: false,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
})
it('renders brand, description and links', () => {
render(<AboutPage />)
expect(screen.getByRole('heading', { name: 'Nex' })).toBeInTheDocument()
expect(screen.getByText('AI Gateway - 统一的大模型 API 网关')).toBeInTheDocument()
expect(screen.getByText('GitHub')).toHaveAttribute('href', 'https://github.com/nex/gateway')
})
it('shows frontend and backend versions', () => {
render(<AboutPage />)
expect(screen.getByText('前端版本')).toBeInTheDocument()
expect(screen.getAllByText('0.1.0').length).toBeGreaterThan(0)
expect(screen.getByText('abc1234')).toBeInTheDocument()
expect(screen.getByText('2026-05-05T00:00:00Z')).toBeInTheDocument()
})
it('shows matched status', () => {
render(<AboutPage />)
expect(screen.getByText('版本一致')).toBeInTheDocument()
})
it('shows mismatched status', () => {
mockUseBackendVersion.mockReturnValue({
data: { version: '0.2.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
isError: false,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
render(<AboutPage />)
expect(screen.getByText('版本不一致')).toBeInTheDocument()
expect(screen.getByText(/用于部署诊断/)).toBeInTheDocument()
})
it('shows unknown status for dev backend version', () => {
mockUseBackendVersion.mockReturnValue({
data: { version: 'dev', commit: 'unknown', buildTime: 'unknown' },
isError: false,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
render(<AboutPage />)
expect(screen.getByText('无法判断版本')).toBeInTheDocument()
})
it('shows unavailable status on backend request failure', () => {
mockUseBackendVersion.mockReturnValue({
data: undefined,
isError: true,
isLoading: false,
} as ReturnType<typeof useBackendVersion>)
render(<AboutPage />)
expect(screen.getByText('无法获取后端版本')).toBeInTheDocument()
expect(screen.getByText(/后端版本接口暂时不可用/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { getVersionStatus } from '@/utils/version'
describe('getVersionStatus', () => {
it('returns matched when versions are equal', () => {
expect(getVersionStatus('1.2.3', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('matched')
})
it('returns mismatched when release versions differ', () => {
expect(getVersionStatus('1.2.3', { version: '1.2.4', commit: 'abc', buildTime: 'now' }).kind).toBe('mismatched')
})
it('returns unknown for dev or unknown versions', () => {
expect(getVersionStatus('dev', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
expect(getVersionStatus('1.2.3', { version: 'unknown', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
})
it('returns unavailable on request failure', () => {
expect(getVersionStatus('1.2.3', undefined, true).kind).toBe('unavailable')
})
})

View File

@@ -0,0 +1,6 @@
import type { BackendVersion } from '@/types'
import { request } from './client'
export async function getBackendVersion(): Promise<BackendVersion> {
return request<BackendVersion>('GET', '/api/version')
}

View File

@@ -1,7 +1,15 @@
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react'
import {
ServerIcon,
ChartLineIcon,
SettingIcon,
InfoCircleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'tdesign-icons-react'
import { Layout, Menu, Button } from 'tdesign-react'
import { APP_NAME } from '@/constants/app'
const { MenuItem } = Menu
@@ -14,7 +22,8 @@ export function AppLayout() {
if (location.pathname === '/providers') return '供应商管理'
if (location.pathname === '/stats') return '用量统计'
if (location.pathname === '/settings') return '设置'
return 'AI Gateway'
if (location.pathname === '/about') return '关于'
return APP_NAME
}
const asideWidth = collapsed ? '64px' : '232px'
@@ -44,15 +53,18 @@ export function AppLayout() {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
fontSize: '1.25rem',
fontWeight: 600,
}}
>
{!collapsed && 'AI Gateway'}
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 28, height: 28 }} />
{!collapsed && APP_NAME}
</div>
}
operations={
<Button
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
variant='text'
shape='square'
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
@@ -70,6 +82,9 @@ export function AppLayout() {
<MenuItem value='/settings' icon={<SettingIcon />}>
</MenuItem>
<MenuItem value='/about' icon={<InfoCircleIcon />}>
</MenuItem>
</Menu>
</Layout.Aside>
<Layout style={{ marginLeft: asideWidth }}>

View File

@@ -0,0 +1,4 @@
export const APP_NAME = 'Nex'
export const APP_DESCRIPTION = 'AI Gateway - 统一的大模型 API 网关'
export const APP_WEBSITE = 'https://github.com/nex/gateway'
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev'

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import * as api from '@/api/version'
export const versionKeys = {
backend: ['version', 'backend'] as const,
}
export function useBackendVersion() {
return useQuery({
queryKey: versionKeys.backend,
queryFn: api.getBackendVersion,
})
}

View File

@@ -0,0 +1,77 @@
import { Alert, Card, Descriptions, Link, Space, Tag } from 'tdesign-react'
import { APP_DESCRIPTION, APP_NAME, APP_VERSION, APP_WEBSITE } from '@/constants/app'
import { useBackendVersion } from '@/hooks/useVersion'
import type { VersionStatusKind } from '@/types'
import { getVersionStatus } from '@/utils/version'
const statusTheme: Record<VersionStatusKind, 'success' | 'warning' | 'default'> = {
matched: 'success',
mismatched: 'warning',
unknown: 'default',
unavailable: 'warning',
}
export default function AboutPage() {
const { data: backendVersion, isError, isLoading } = useBackendVersion()
const versionStatus = getVersionStatus(APP_VERSION, backendVersion, isError)
return (
<div style={{ display: 'grid', gap: 'var(--td-comp-margin-l)' }}>
<Card bordered={false} hoverShadow>
<Space align='center' size='large'>
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 56, height: 56 }} />
<div>
<h1 style={{ margin: 0, fontSize: '2rem' }}>{APP_NAME}</h1>
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
{APP_DESCRIPTION}
</p>
</div>
</Space>
</Card>
<div style={{ position: 'relative' }}>
<Card bordered={false} hoverShadow>
{(versionStatus.kind === 'mismatched' || versionStatus.kind === 'unavailable') && (
<Alert
theme='warning'
message={versionStatus.description}
style={{ marginBottom: 'var(--td-comp-margin-l)' }}
/>
)}
<Descriptions
column={2}
itemLayout='vertical'
items={[
{ label: '前端版本', content: APP_VERSION },
{ label: '后端版本', content: isLoading ? '加载中' : backendVersion?.version || 'unknown' },
{ label: '后端提交', content: isLoading ? '加载中' : backendVersion?.commit || 'unknown' },
{ label: '后端构建时间', content: isLoading ? '加载中' : backendVersion?.buildTime || 'unknown' },
]}
/>
</Card>
<Tag
theme={statusTheme[versionStatus.kind]}
variant='light'
shape='round'
style={{ position: 'absolute', top: 'var(--td-comp-paddingLR-l)', right: 'var(--td-comp-paddingLR-l)' }}
>
{versionStatus.label}
</Tag>
</div>
<Card bordered={false} hoverShadow>
<Space breakLine size='large'>
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
GitHub
</Link>
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
</Link>
<Link href={`${APP_WEBSITE}/blob/main/LICENSE`} target='_blank' theme='primary'>
License
</Link>
</Space>
</Card>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { AppLayout } from '@/components/AppLayout'
const ProvidersPage = lazy(() => import('@/pages/Providers'))
const StatsPage = lazy(() => import('@/pages/Stats'))
const SettingsPage = lazy(() => import('@/pages/Settings'))
const AboutPage = lazy(() => import('@/pages/About'))
const NotFound = lazy(() => import('@/pages/NotFound'))
export function AppRoutes() {
@@ -17,6 +18,7 @@ export function AppRoutes() {
<Route path='providers' element={<ProvidersPage />} />
<Route path='stats' element={<StatsPage />} />
<Route path='settings' element={<SettingsPage />} />
<Route path='about' element={<AboutPage />} />
<Route path='*' element={<NotFound />} />
</Route>
</Routes>

View File

@@ -62,6 +62,20 @@ export interface StatsQueryParams {
endDate?: string
}
export interface BackendVersion {
version: string
commit: string
buildTime: string
}
export type VersionStatusKind = 'matched' | 'mismatched' | 'unknown' | 'unavailable'
export interface VersionStatus {
kind: VersionStatusKind
label: string
description: string
}
export class ApiError extends Error {
status: number
code?: string

View File

@@ -0,0 +1,42 @@
import type { BackendVersion, VersionStatus } from '@/types'
function isUnknownVersion(version: string | undefined): boolean {
const normalized = version?.trim().toLowerCase()
return !normalized || normalized === 'dev' || normalized === 'unknown'
}
export function getVersionStatus(
frontendVersion: string,
backendVersion?: BackendVersion,
hasError = false
): VersionStatus {
if (hasError) {
return {
kind: 'unavailable',
label: '无法获取后端版本',
description: '后端版本接口暂时不可用,当前仅展示前端版本。',
}
}
if (!backendVersion || isUnknownVersion(frontendVersion) || isUnknownVersion(backendVersion.version)) {
return {
kind: 'unknown',
label: '无法判断版本',
description: '当前处于开发构建或版本信息不完整,不判定为版本错误。',
}
}
if (frontendVersion === backendVersion.version) {
return {
kind: 'matched',
label: '版本一致',
description: '前端和后端来自同一版本构建。',
}
}
return {
kind: 'mismatched',
label: '版本不一致',
description: '前后端版本不同,该状态用于部署诊断,不影响当前功能使用。',
}
}

View File

@@ -3,4 +3,5 @@ go 1.26.2
use (
backend
embedfs
versionctl
)

View File

@@ -4,8 +4,6 @@ cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQ
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -14,6 +12,7 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
@@ -26,8 +25,6 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
@@ -48,6 +45,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
@@ -66,8 +64,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-04

View File

@@ -0,0 +1,105 @@
## Context
Nex 已经具备统一版本源:仓库根目录 `VERSION` 是权威版本,`versionctl sync` 会同步 `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION`Go 二进制构建通过 `-ldflags` 注入 `backend/pkg/buildinfo`。当前缺口在运行时可见性:前端只能知道自身构建版本,无法从后端获取 server/desktop 二进制版本,也无法判断前后端是否来自同一版本。
前端侧边栏当前在 `AppLayout` 中以 `AI Gateway` 作为品牌文字,折叠后 logo 区域为空HTML title 也仍为 `AI Gateway`。About 页面只展示名称、描述和链接,缺少版本、构建信息、状态反馈和现代化信息层次。图标资源方面,前端 public 目录维护独立 SVG favicon 和未使用的 `icons.svg`,而桌面托盘和打包资源已经使用仓库 `assets/icon.png``assets/icon.ico``assets/icon.icns`。desktop 代码中的 `appName/appTooltip` 已经是 `Nex`,但现有 `desktop-app` spec 仍保留旧 tooltip 文案,需要借本次 change 同步。
本变更跨越前端页面、前端 API、后端管理接口、desktop 静态资源服务、README 和 OpenSpec因此需要先固定实现边界避免实现阶段出现多个版本来源或重复图标资源。
## Goals / Non-Goals
**Goals:**
- 统一用户可见应用名称为 `Nex`覆盖前端侧边栏、HTML title 和相关测试断言,`AI Gateway` 作为产品描述保留。
- 侧边栏展开态显示统一图标和 `Nex`,折叠态仍显示统一图标。
- 前端 favicon/public 图标复用仓库 `assets/icon.png`,清理不再使用的 SVG public 图标资源。
- About 页面展示前端版本、后端版本、后端 commit、后端 build_time 和版本匹配状态。
- 后端在 server 和 desktop 模式下都提供 `GET /api/version`
- 明确并落地前端样式优先级TDesign 组件 props、TDesign tokens、SCSS。
- 补充单元测试、组件测试、后端路由测试和必要的 E2E 覆盖。
- 同步更新根 README、frontend README、backend README。
**Non-Goals:**
- 不改变版本权威来源,仍以根目录 `VERSION` 为唯一版本源。
- 不新增前端或后端依赖。
- 不引入用户认证、权限控制或配置项开关。
- 不让 About 页面阻断业务功能;版本不一致仅提示,不禁止使用。
- 不重构所有已有内联样式,只处理本次触达的品牌和 About 页面相关样式。
## Decisions
### Decision: 后端版本信息复用 buildinfo 并通过 `/api/version` 暴露
后端 SHALL 新增 `GET /api/version` 管理接口,响应字段为 `version``commit``build_time`,字段值来自 `buildinfo.Version()``buildinfo.Commit()``buildinfo.BuildTime()`
选择 `/api/version` 的原因:管理接口已经统一使用 `/api/*`,前端 API 客户端也以该路径族访问后端;`/health` 应保持存活检查语义,不混入构建元数据。选择 `buildinfo` 的原因Makefile 已在 server 和 desktop 构建中注入版本、commit 和 buildTime运行时读取 `VERSION` 会破坏发布产物独立性。
备选方案:扩展 `/health`。放弃原因是健康检查会被监控系统频繁调用,混入构建信息容易模糊职责,并且前端语义上需要的是管理信息而不是存活探针。
### Decision: server 和 desktop 分别注册同一个版本接口
server 和 desktop 当前拥有独立 `setupRoutes`,因此两个入口都 MUST 注册 `GET /api/version`。desktop 还需要确保该路径不会落入 SPA fallback 或协议代理路由。
备选方案:只在 server 注册。放弃原因是桌面应用使用独立路由装配和静态资源服务,用户在 desktop 模式下同样需要 About 页面展示后端版本。
### Decision: 前端版本使用 `VITE_APP_VERSION`,不运行时读取 package.json
前端 SHALL 使用 `import.meta.env.VITE_APP_VERSION` 作为自身版本,缺失时降级为 `dev``unknown` 显示。该值由版本同步工具写入 `.env.*`,符合现有构建版本注入规则。
备选方案:在前端运行时请求或打包 `package.json`。放弃原因是会产生第二个版本读取路径,并可能在 desktop 嵌入构建、生产构建和测试环境中产生不一致。
### Decision: About 页面只提示版本一致性,不阻断功能
About 页面 SHALL 根据前端版本和后端版本显示状态:一致、不一致、开发构建无法判断、后端版本获取失败。状态通过 TDesign `Tag` 和必要时的 `Alert` 展示。
判断规则:前端版本等于后端版本时为一致;任一版本为 `dev``unknown`、空值时为无法判断;请求失败时为后端版本获取失败;其余不相等时为不一致。
备选方案:版本不一致时阻断或弹窗提示。放弃原因是版本检查用于部署诊断,不能影响现有配置和代理能力。
### Decision: 图标资源统一到 `assets/icon.png` 并固定运行时路径
前端 public 图标 SHALL 由仓库 `assets/icon.png` 派生,实施时将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`,前端入口 SHALL 使用 `/icon.png` 作为 PNG favicon 路径。`frontend/public/favicon.svg` 不再作为 favicon 来源,`frontend/public/icons.svg` 经确认未被引用后 SHALL 删除。
Vite 会将 `frontend/public/icon.png` 输出到 dist 根目录,因此 desktop 静态服务 SHALL 显式服务 `/icon.png` 并读取嵌入 dist 中的 `icon.png`。README SHALL 标注 `frontend/public/icon.png` 来源于根目录 `assets/icon.png`,后续更新应用图标时应以根目录资源为准并同步 public 镜像。
备选方案:继续保留 SVG favicon。放弃原因是会继续存在两套应用图标来源与用户要求的统一资源方向不一致。
### Decision: 前端样式优先级写入 README 并指导实现
实现视觉效果时,前端 SHALL 优先使用 TDesign 组件 props例如 `Card``bordered``hoverShadow``headerBordered``Tag``theme``variant``shape``Row/Col` 的响应式 props。组件 props 不足时使用 TDesign tokens例如 `var(--td-text-color-secondary)``var(--td-brand-color)`。只有 props 和 tokens 无法表达布局、响应式或品牌细节时才使用 SCSS。
备选方案:直接新增 SCSS Modules 完成全部视觉。放弃原因是项目已经在 OpenSpec 中要求优先使用 TDesign 样式体系,过多 SCSS 会增加覆盖组件内部样式的风险。
### Decision: About 页面采用信息面板布局
About 页面 SHALL 以三个独立 `Card` 垂直排列呈现:顶部品牌卡片展示图标、`Nex`、产品描述中部版本信息卡片展示前端版本、后端版本、commit、build_time版本状态 Tag 以绝对定位浮动在卡片右上角;下部链接卡片展示外部链接入口。
布局示意:
```text
┌─────────────────────────────────────────────┐
│ Icon Nex │
│ AI Gateway - 统一的大模型 API 网关 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 前端版本 后端版本 构建信息 [Tag] │
│ v0.1.0 v0.1.0 commit/time │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ GitHub / 文档 / License │
└─────────────────────────────────────────────┘
```
### Decision: desktop-app spec 同步现有 Nex tooltip
desktop 代码已经通过 `appName = "Nex"``appTooltip = appName` 使用统一应用名称,现有 `desktop-app` spec 中 tooltip 仍写 `AI Gateway`。本次 change 已触达 `desktop-app` capability因此 delta spec SHALL 同步修正托盘 tooltip 要求为 `Nex`,避免归档后主 spec 与当前代码继续漂移。
## Risks / Trade-offs
- `/api/version` 在未通过 Makefile 运行的本地 Go 进程中可能返回 `dev/unknown/unknown` → About 页面将其视为开发构建,显示“无法判断”而不是错误。
- desktop 与 server 路由重复维护,可能漏注册 → 任务中明确分别添加路由测试,覆盖两个入口。
- 前端 public PNG 与根 `assets/icon.png` 可能漂移 → README 明确来源;实现阶段复制 `assets/icon.png``frontend/public/icon.png`,并通过构建验证确认 `/icon.png` 可用。
- 删除 `frontend/public/icons.svg` 可能影响隐藏引用 → 已通过全文搜索未发现引用;实现阶段删除前再次全文确认,删除后运行前端测试和构建验证。
- 样式优先级与现有 README “SCSS Modules” 表述可能冲突 → 本变更同步更新 README以 TDesign props/tokens 优先作为新的明确规则。
- 版本不一致提示可能被误解为严重故障 → 文案应说明该状态用于部署诊断,不影响当前功能使用。

View File

@@ -0,0 +1,34 @@
## Why
当前前端侧边栏仍显示旧的 `AI Gateway` 文案折叠后品牌区域为空About 页面信息展示较简陋,无法展示和判断前后端构建版本是否匹配。同时前端 public 图标资源与仓库统一的 `assets/icon.png` 不一致,并存在未使用的 SVG 图标资源,增加了品牌和资源维护成本。
## What Changes
- 将前端侧边栏品牌统一为 `Nex`,展开时显示统一图标和应用名称,折叠时仍显示图标。
- 将前端 favicon/public 图标统一复用仓库 `assets/icon.png`,运行时统一使用 `/icon.png`;替换当前 `/favicon.svg` 引用并清理未使用的 `frontend/public/icons.svg` 资源。
- 将前端 HTML 标题等用户可见应用名称同步为 `Nex`,保留 `AI Gateway` 作为产品描述。
- 重新设计 About 页面信息结构,使用三个独立卡片分别展示品牌、版本信息和外部链接,版本状态 Tag 浮动在版本信息卡片右上角。
- 新增后端管理接口 `GET /api/version`,暴露构建注入的 `version``commit``build_time`,供前端判断前后端版本一致性。
- 在 server 和 desktop 两种启动模式下都注册版本接口,并确保 desktop 静态资源路由支持新的 PNG 图标路径。
- 明确前端样式优先级TDesign 组件 props 优先,其次使用 TDesign tokens最后才在无法通过前两者表达时使用 SCSS并同步更新 README。
- 为菜单品牌、About 页面、版本接口、版本匹配状态和资源清理补充测试。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `frontend`: 侧边栏品牌标识、折叠态 logo、HTML 标题、前端样式优先级和 public 图标资源使用规则发生变化。
- `about-page`: About 页面从简单品牌信息扩展为现代化信息面板,并展示前后端版本与匹配状态。
- `repository-versioning`: 后端需要通过管理接口暴露构建版本信息,前端需要使用构建注入版本与后端版本进行一致性判断。
- `desktop-app`: desktop 模式需要支持新的 PNG 图标静态资源路径,保证版本接口作为 API 路由处理,并同步现有 `Nex` 托盘 tooltip 规范。
## Impact
- 前端:`frontend/src/components/AppLayout``frontend/src/pages/About``frontend/src/api``frontend/src/hooks``frontend/src/types``frontend/index.html``frontend/public`、前端测试和 E2E 测试。
- 后端:`backend/internal/handler``backend/cmd/server``backend/cmd/desktop`、后端测试。
- 文档与规范:根 `README.md``frontend/README.md``backend/README.md`、相关 OpenSpec specs。
- API新增 `GET /api/version` 管理接口;不引入新依赖。

View File

@@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: 关于页面
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态。
#### Scenario: 显示关于页面
- **WHEN** 用户访问 `/about` 路径
- **THEN** 前端 SHALL 显示关于页面
- **THEN** 页面 SHALL 展示应用名称 `Nex`
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
#### Scenario: 页面布局
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
- **THEN** 每个 Card SHALL 设置 `bordered={false}``hoverShadow`
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
#### Scenario: 品牌卡片
- **WHEN** 渲染品牌卡片
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
#### Scenario: 版本信息卡片
- **WHEN** 渲染版本信息卡片
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme``variant``shape` props
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
#### Scenario: 链接卡片
- **WHEN** 渲染链接卡片
- **THEN** 卡片 SHALL 展示项目外部链接
#### Scenario: 展示前端版本
- **WHEN** 渲染关于页面
- **THEN** 页面 SHALL 显示前端版本号
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
#### Scenario: 展示后端版本
- **WHEN** 渲染关于页面且后端版本接口请求成功
- **THEN** 页面 SHALL 显示后端 `version`
- **THEN** 页面 SHALL 显示后端 `commit`
- **THEN** 页面 SHALL 显示后端 `build_time`
#### Scenario: 判断版本一致
- **WHEN** 前端版本号与后端版本号相同
- **THEN** 页面 SHALL 显示版本一致状态
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
#### Scenario: 判断版本不一致
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
- **THEN** 页面 SHALL 显示版本不一致状态
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
#### Scenario: 后端版本无法判断
- **WHEN** 后端版本号为 `dev``unknown` 或空值
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
#### Scenario: 后端版本获取失败
- **WHEN** 请求后端版本接口失败
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
- **THEN** 页面 SHALL 保留前端版本信息
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃

View File

@@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
#### Scenario: 托盘图标显示
- **WHEN** 桌面应用启动成功
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示 `Nex`
#### Scenario: 托盘菜单显示
- **WHEN** 用户点击托盘图标(左键或右键)
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826`
#### Scenario: 退出应用
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统优雅关闭后端服务
- **AND** 托盘图标消失
- **AND** 应用进程退出
### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
#### Scenario: API 请求路由
- **WHEN** 请求路径以 `/api/``/health` 开头
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
#### Scenario: 版本接口路由
- **WHEN** desktop 模式收到 `GET /api/version` 请求
- **THEN** 请求 SHALL 由版本信息 handler 处理
- **THEN** 响应 SHALL 为 API JSON 响应
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: 协议代理请求路由
- **WHEN** 请求路径以 `/openai/``/anthropic/` 开头
- **THEN** 请求 SHALL 被视为协议代理请求或返回 API 风格 404
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: OpenAI 代理路由
- **WHEN** desktop 模式收到 `/openai/v1/chat/completions` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `openai`
#### Scenario: Anthropic 代理路由
- **WHEN** desktop 模式收到 `/anthropic/v1/messages` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `anthropic`
#### Scenario: 静态资源路由
- **WHEN** 请求路径为 `/assets/*`
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: PNG Favicon 路由
- **WHEN** 请求路径为 `/icon.png`
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
- **THEN** 返回 `index.html`(支持前端 SPA 路由)

View File

@@ -0,0 +1,121 @@
## MODIFIED Requirements
### Requirement: 样式体系
前端样式 SHALL 优先使用 TDesign 组件 props其次使用 TDesign tokens最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
#### Scenario: TDesign 组件 Props 优先
- **WHEN** 实现组件视觉效果
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
#### Scenario: TDesign Tokens 作为第二优先级
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff``#e7e7e7``#999` 等颜色值
#### Scenario: CSS Variables 主题微调
- **WHEN** 需要调整全局视觉风格
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables`--td-*`)进行覆盖
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
#### Scenario: SCSS 兜底使用
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
- **THEN** 前端 MAY 使用 SCSS 作为补充
- **THEN** SCSS SHALL 只承载必要的补充样式
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css
### Requirement: 提供响应式布局
前端 SHALL 使用 TDesign Layout 提供侧边栏导航布局。
#### Scenario: 桌面布局
- **WHEN** 在桌面屏幕上查看前端
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
#### Scenario: 侧边栏折叠布局
- **WHEN** 用户折叠侧边栏
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
- **THEN** Menu logo 区域 SHALL 保留应用图标
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
#### Scenario: 页面内容区域
- **WHEN** 显示页面内容
- **THEN** 内容区域 SHALL 在 `Layout.Content` 中渲染
- **THEN** 页面之间 SHALL 通过 React Router Outlet 渲染
#### Scenario: Header 区域
- **WHEN** 渲染页面 Header
- **THEN** Header SHALL 仅显示当前页面标题
- **THEN** Header SHALL 不包含导航菜单
- **THEN** Header 背景色 SHALL 使用 `var(--td-bg-color-container)` Token
- **THEN** Header 底部分割线 SHALL 使用 `var(--td-component-stroke)` Token
### Requirement: 提供侧边栏导航
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
#### Scenario: 侧边栏内容
- **WHEN** 渲染侧边栏
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
- **THEN** 侧边栏 SHALL 包含导航菜单
- **THEN** 导航菜单项 SHALL 包含供应商管理ServerIcon 图标、用量统计ChartLineIcon 图标、设置SettingIcon 图标、关于InfoCircleIcon 图标)
#### Scenario: 侧边栏折叠品牌显示
- **WHEN** 侧边栏处于折叠状态
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
- **THEN** 侧边栏顶部 SHALL NOT 为空白
#### Scenario: 导航菜单交互
- **WHEN** 用户点击导航中的"供应商管理"
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"用量统计"
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"设置"
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
- **WHEN** 用户点击导航中的"关于"
- **THEN** 前端 SHALL 导航到 `/about` 并高亮当前菜单项
## ADDED Requirements
### Requirement: 统一 public 图标资源
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
#### Scenario: 使用 PNG favicon
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
#### Scenario: HTML 标题使用统一应用名称
- **WHEN** 前端页面加载 HTML 入口
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
#### Scenario: 清理未使用 public SVG 图标
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: 后端运行时版本查询
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
#### Scenario: 查询后端版本信息
- **WHEN** 客户端请求 `GET /api/version`
- **THEN** 后端 SHALL 返回 HTTP 200
- **THEN** 响应 JSON SHALL 包含 `version` 字段
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
#### Scenario: 版本信息来源于构建注入
- **WHEN** 后端返回版本信息
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
#### Scenario: 本地开发构建降级值
- **WHEN** 后端未通过构建参数注入版本元数据
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
### Requirement: 前后端版本一致性诊断
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
#### Scenario: 前端读取构建版本
- **WHEN** 前端渲染版本信息
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
#### Scenario: 诊断版本匹配
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
- **THEN** 前端 SHALL 将版本状态判定为一致
#### Scenario: 诊断版本不匹配
- **WHEN** 前端版本号和后端版本号均可判断且不相同
- **THEN** 前端 SHALL 将版本状态判定为不一致
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
#### Scenario: 诊断版本不可判断
- **WHEN** 任一版本号为空、`dev``unknown`
- **THEN** 前端 SHALL 将版本状态判定为无法判断
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致

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