Compare commits
12 Commits
4c62c071fb
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c401f7ae6 | |||
| a9972360c2 | |||
| b00fa4dcee | |||
| 92525b39c3 | |||
| 38a2555c7b | |||
| 9622d44aac | |||
| 155244433f | |||
| 2c043c6cf7 | |||
| f5c82b6980 | |||
| 9105a36097 | |||
| f1ee646ca4 | |||
| b9b487c591 |
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
* 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
|
||||
151
.github/workflows/release.yml
vendored
Normal file
151
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
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@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
|
||||
- name: Verify tag and VERSION
|
||||
id: version
|
||||
run: |
|
||||
version=$(go run ./backend/cmd/versionctl print)
|
||||
go run ./backend/cmd/versionctl verify-tag "${GITHUB_REF_NAME}"
|
||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-linux:
|
||||
name: Build Linux Assets
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Linux desktop build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev
|
||||
|
||||
- name: Build Linux release assets
|
||||
run: make release-assets-linux
|
||||
|
||||
- name: Upload Linux release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux
|
||||
path: build/release/*
|
||||
|
||||
build-windows:
|
||||
name: Build Windows Assets
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup MSYS2 toolchain
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
update: true
|
||||
install: >-
|
||||
make
|
||||
mingw-w64-x86_64-gcc
|
||||
|
||||
- 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
|
||||
path: build/release/*
|
||||
|
||||
build-macos:
|
||||
name: Build macOS Assets
|
||||
needs: prepare
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- 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/*
|
||||
|
||||
draft-release:
|
||||
name: Create Draft Release
|
||||
needs: [prepare, build-linux, build-windows, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
path: 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/*
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -411,4 +411,5 @@ skills-lock.json
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
embedfs/frontend-dist/
|
||||
embedfs/frontend-dist/
|
||||
backend/cmd/desktop/rsrc_windows_*.syso
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"files.eol": "\n"
|
||||
}
|
||||
184
LICENSE
Normal file
184
LICENSE
Normal 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.
|
||||
401
Makefile
401
Makefile
@@ -1,253 +1,232 @@
|
||||
.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 \
|
||||
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-linux release-assets-windows release-assets-macos \
|
||||
_backend-lint _backend-test _backend-clean _backend-build \
|
||||
_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
|
||||
|
||||
VERSION := $(shell go run ./backend/cmd/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)
|
||||
GO_LDFLAGS_WIN := $(GO_LDFLAGS) -H=windowsgui
|
||||
RELEASE_DIR := build/release
|
||||
SERVER_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server linux amd64)
|
||||
SERVER_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server windows amd64)
|
||||
SERVER_DARWIN_AMD64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin amd64)
|
||||
SERVER_DARWIN_ARM64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin arm64)
|
||||
DESKTOP_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop linux)
|
||||
DESKTOP_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop windows)
|
||||
DESKTOP_MACOS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop macos)
|
||||
|
||||
# ============================================
|
||||
# 顶层便捷命令
|
||||
# 全局命令
|
||||
# ============================================
|
||||
|
||||
dev:
|
||||
@echo "🚀 Starting development environment..."
|
||||
@$(MAKE) -j2 backend-dev frontend-dev
|
||||
lint: _backend-lint _frontend-check
|
||||
@printf 'Lint complete\n'
|
||||
|
||||
build: backend-build frontend-build
|
||||
@echo "✅ Build complete"
|
||||
test: _backend-test _frontend-test _desktop-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 ./backend/cmd/versionctl sync
|
||||
|
||||
backend-run:
|
||||
cd backend && go run ./cmd/server
|
||||
|
||||
backend-dev:
|
||||
cd backend && go run ./cmd/server
|
||||
|
||||
backend-test:
|
||||
cd backend && go test ./internal/... ./pkg/... ./tests/... ./cmd/server/... -v
|
||||
|
||||
backend-test-all:
|
||||
cd backend && go test ./... -v
|
||||
|
||||
backend-test-unit:
|
||||
cd backend && go test ./internal/... ./pkg/... -v
|
||||
|
||||
backend-test-integration:
|
||||
cd backend && go test ./tests/... -v
|
||||
|
||||
backend-test-coverage:
|
||||
cd backend && go test ./... -coverprofile=coverage.out
|
||||
cd backend && go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: backend/coverage.html"
|
||||
|
||||
backend-lint:
|
||||
cd backend && go tool golangci-lint run ./...
|
||||
|
||||
backend-clean:
|
||||
rm -rf backend/bin/ backend/coverage.out backend/coverage.html
|
||||
|
||||
backend-deps:
|
||||
cd backend && go mod tidy
|
||||
|
||||
backend-generate:
|
||||
cd backend && go generate ./...
|
||||
|
||||
backend-db-up:
|
||||
@echo "Running database migration up..."
|
||||
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" up
|
||||
|
||||
backend-db-down:
|
||||
@echo "Running database migration down..."
|
||||
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" down
|
||||
|
||||
backend-db-status:
|
||||
@echo "Checking database migration status..."
|
||||
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" status
|
||||
|
||||
backend-db-create:
|
||||
@read -p "Migration name: " name; \
|
||||
cd backend && goose -dir migrations/sqlite create $$name sql; \
|
||||
cd backend && goose -dir migrations/mysql create $$name sql
|
||||
version-check:
|
||||
go run ./backend/cmd/versionctl check
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
frontend-build: frontend-install
|
||||
cd frontend && bun run build
|
||||
|
||||
frontend-dev: frontend-install
|
||||
cd frontend && bun dev
|
||||
|
||||
frontend-test: frontend-install
|
||||
cd frontend && bun run test
|
||||
|
||||
frontend-test-watch: frontend-install
|
||||
cd frontend && bun run test:watch
|
||||
|
||||
frontend-test-coverage: frontend-install
|
||||
cd frontend && bun run test:coverage
|
||||
|
||||
frontend-test-e2e: frontend-install
|
||||
cd frontend && bun run test:e2e
|
||||
|
||||
frontend-lint: frontend-install
|
||||
cd frontend && bun run lint
|
||||
|
||||
frontend-clean:
|
||||
rm -rf frontend/dist frontend/.next frontend/node_modules frontend/coverage frontend/playwright-report frontend/test-results frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# ============================================
|
||||
# 桌面应用
|
||||
# ============================================
|
||||
|
||||
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..."
|
||||
cd frontend && cp .env.desktop .env.production.local
|
||||
cd frontend && bun install && bun run build
|
||||
rm -f frontend/.env.production.local
|
||||
|
||||
desktop-prepare-embedfs:
|
||||
@echo "📦 Preparing embedded filesystem..."
|
||||
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
|
||||
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
|
||||
@echo "📦 Packaging macOS .app..."
|
||||
@printf 'Packaging macOS app bundle...\n'
|
||||
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/; \
|
||||
@if [ -f assets/icon.icns ]; then \
|
||||
cp assets/icon.icns build/Nex.app/Contents/Resources/; \
|
||||
else \
|
||||
echo "⚠️ 未找到 assets/AppIcon.icns"; \
|
||||
printf 'Missing assets/icon.icns\n'; \
|
||||
fi
|
||||
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
|
||||
if [ -z "$$MIN_MACOS_VERSION" ]; then \
|
||||
echo "❌ 无法读取 macOS 最低系统版本"; \
|
||||
printf 'Unable to read macOS minimum version\n'; \
|
||||
exit 1; \
|
||||
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
|
||||
go run ./backend/cmd/versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
|
||||
chmod +x build/Nex.app/Contents/MacOS/nex
|
||||
@echo "✅ macOS app packaged: build/Nex.app"
|
||||
@printf 'macOS desktop build complete\n'
|
||||
|
||||
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-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource
|
||||
@printf 'Building Windows desktop...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "New-Item -ItemType Directory -Path 'build' -Force | Out-Null"
|
||||
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
||||
else
|
||||
mkdir -p build
|
||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
||||
endif
|
||||
@printf 'Windows desktop build complete\n'
|
||||
|
||||
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-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs
|
||||
@printf 'Building Linux desktop...\n'
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-linux-amd64 ./cmd/desktop
|
||||
@printf 'Linux desktop build complete\n'
|
||||
|
||||
desktop-dev: desktop-prepare-frontend desktop-prepare-embedfs
|
||||
@echo "🖥️ Starting desktop app in dev mode..."
|
||||
cd backend && go run ./cmd/desktop
|
||||
desktop-lint: _backend-lint _frontend-check
|
||||
@printf 'Desktop lint complete\n'
|
||||
|
||||
desktop-test:
|
||||
desktop-test: _desktop-test
|
||||
@printf 'Desktop tests passed\n'
|
||||
|
||||
desktop-clean: _desktop-clean
|
||||
@printf 'Desktop artifacts cleaned\n'
|
||||
|
||||
_desktop-test:
|
||||
cd backend && go test ./cmd/desktop/... -v
|
||||
|
||||
desktop-clean:
|
||||
rm -rf build/ embedfs/assets embedfs/frontend-dist
|
||||
_desktop-clean:
|
||||
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso
|
||||
|
||||
_desktop-prepare-frontend: _frontend-install
|
||||
@printf 'Preparing frontend for desktop...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "Copy-Item -LiteralPath 'frontend/.env.desktop' -Destination 'frontend/.env.production.local' -Force"
|
||||
cd frontend && bun run build
|
||||
powershell -NoProfile -Command "Remove-Item -LiteralPath 'frontend/.env.production.local' -Force -ErrorAction SilentlyContinue"
|
||||
else
|
||||
cd frontend && cp .env.desktop .env.production.local
|
||||
cd frontend && bun run build
|
||||
rm -f frontend/.env.production.local
|
||||
endif
|
||||
|
||||
_desktop-prepare-embedfs:
|
||||
@printf 'Preparing embedded filesystem...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "Remove-Item -LiteralPath 'embedfs/assets' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath 'embedfs/frontend-dist' -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item -LiteralPath 'assets' -Destination 'embedfs/assets' -Recurse; Copy-Item -LiteralPath 'frontend/dist' -Destination 'embedfs/frontend-dist' -Recurse"
|
||||
else
|
||||
rm -rf embedfs/assets embedfs/frontend-dist
|
||||
cp -r assets embedfs/assets
|
||||
cp -r frontend/dist embedfs/frontend-dist
|
||||
endif
|
||||
|
||||
_desktop-prepare-windows-resource:
|
||||
@printf 'Preparing Windows executable icon...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso
|
||||
else
|
||||
@if command -v x86_64-w64-mingw32-windres >/dev/null 2>&1; then \
|
||||
cd backend/cmd/desktop && x86_64-w64-mingw32-windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
|
||||
elif command -v windres >/dev/null 2>&1; then \
|
||||
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
|
||||
else \
|
||||
printf 'Missing windres for Windows icon resource generation\n'; \
|
||||
exit 1; \
|
||||
fi
|
||||
endif
|
||||
|
||||
# ============================================
|
||||
# 清理
|
||||
# 发布资产
|
||||
# ============================================
|
||||
|
||||
clean: backend-clean frontend-clean desktop-clean
|
||||
@echo "✅ Clean complete"
|
||||
release-assets-linux: version-check desktop-build-linux
|
||||
rm -rf "$(RELEASE_DIR)"
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-amd64 ./cmd/server
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_LINUX_ASSET)" nex-server-linux-amd64
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(DESKTOP_LINUX_ASSET)" nex-linux-amd64
|
||||
|
||||
release-assets-windows: version-check desktop-build-win
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "Remove-Item -LiteralPath '$(RELEASE_DIR)' -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path '$(RELEASE_DIR)' -Force | Out-Null"
|
||||
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-server-win-amd64.exe ./cmd/server
|
||||
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-server-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(SERVER_WINDOWS_ASSET)' -Force"
|
||||
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(DESKTOP_WINDOWS_ASSET)' -Force"
|
||||
else
|
||||
@printf 'release-assets-windows requires Windows\n'
|
||||
@exit 1
|
||||
endif
|
||||
|
||||
release-assets-macos: version-check desktop-build-mac
|
||||
rm -rf "$(RELEASE_DIR)"
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-amd64 ./cmd/server
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-arm64 ./cmd/server
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_AMD64_ASSET)" nex-server-darwin-amd64
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_ARM64_ASSET)" nex-server-darwin-arm64
|
||||
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$(DESKTOP_MACOS_ASSET)"
|
||||
|
||||
# ============================================
|
||||
# 共享 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
|
||||
|
||||
_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
|
||||
|
||||
167
README.md
167
README.md
@@ -36,7 +36,7 @@ nex/
|
||||
│
|
||||
├── assets/ # 应用资源
|
||||
│ ├── icon.png # 托盘图标
|
||||
│ ├── AppIcon.icns # macOS 应用图标
|
||||
│ ├── icon.icns # macOS 应用图标
|
||||
│ └── icon.ico # Windows 应用图标
|
||||
│
|
||||
└── README.md # 本文件
|
||||
@@ -47,7 +47,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` 配置
|
||||
@@ -109,9 +109,6 @@ make desktop-build-win
|
||||
|
||||
# Linux
|
||||
make desktop-build-linux
|
||||
|
||||
# 构建所有平台
|
||||
make desktop-build
|
||||
```
|
||||
|
||||
**使用桌面应用**:
|
||||
@@ -132,50 +129,54 @@ 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 转发到后端。
|
||||
|
||||
## 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。
|
||||
|
||||
### 管理接口(对前端)
|
||||
|
||||
#### 供应商管理
|
||||
@@ -271,53 +272,101 @@ 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` 由仓库工具同步,不能手工漂移
|
||||
|
||||
### 本地版本演进
|
||||
|
||||
1. 手工修改根目录 `VERSION` 为新的 `x.y.z`
|
||||
2. 同步镜像文件:
|
||||
|
||||
```bash
|
||||
make version-sync
|
||||
```
|
||||
|
||||
3. 校验版本一致性:
|
||||
|
||||
```bash
|
||||
make version-check
|
||||
```
|
||||
|
||||
4. 提交版本变更后,创建发布 tag:
|
||||
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "Release vX.Y.Z"
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
### 本地生成发布资产
|
||||
|
||||
```bash
|
||||
# Linux: server + desktop
|
||||
make release-assets-linux
|
||||
|
||||
# Windows: server + desktop(需在 Windows 环境执行)
|
||||
make release-assets-windows
|
||||
|
||||
# macOS: darwin-amd64 server、darwin-arm64 server、desktop universal
|
||||
make release-assets-macos
|
||||
```
|
||||
|
||||
生成的版本化发布资产位于 `build/release/`。
|
||||
|
||||
### GitHub Draft Release
|
||||
|
||||
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
|
||||
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 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 +375,4 @@ make frontend-clean # 清理前端构建产物
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
Apache License 2.0
|
||||
|
||||
Binary file not shown.
@@ -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
BIN
assets/icon.icns
LFS
Normal file
Binary file not shown.
BIN
assets/icon.ico
BIN
assets/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 130 B |
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 131 B |
@@ -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 |
BIN
assets/icons/hicolor/128x128/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/128x128/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/16x16/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/16x16/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/22x22/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/22x22/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/24x24/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/24x24/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/256x256/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/256x256/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/32x32/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/32x32/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/48x48/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/48x48/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/512x512/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/512x512/apps/nex.png
LFS
Normal file
Binary file not shown.
BIN
assets/icons/hicolor/64x64/apps/nex.png
LFS
Normal file
BIN
assets/icons/hicolor/64x64/apps/nex.png
LFS
Normal file
Binary file not shown.
97
backend/Makefile
Normal file
97
backend/Makefile
Normal 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 ./cmd/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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
#### 模型管理
|
||||
@@ -558,9 +584,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 或更高版本
|
||||
|
||||
@@ -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, "\"", "\\\"")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 调用失败")
|
||||
}
|
||||
|
||||
1
backend/cmd/desktop/icon_windows.rc
Normal file
1
backend/cmd/desktop/icon_windows.rc
Normal file
@@ -0,0 +1 @@
|
||||
1 ICON "../../../assets/icon.ico"
|
||||
@@ -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"
|
||||
@@ -151,7 +152,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))
|
||||
}
|
||||
@@ -168,7 +173,8 @@ func main() {
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
|
||||
r.Any("/v1/*path", proxyHandler.HandleProxy)
|
||||
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
|
||||
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
||||
|
||||
providers := r.Group("/api/providers")
|
||||
{
|
||||
@@ -199,12 +205,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"
|
||||
@@ -250,7 +270,10 @@ func setupStaticFiles(r *gin.Engine) {
|
||||
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 +310,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 +319,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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nex/embedfs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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 +31,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 +99,115 @@ func TestSetupStaticFiles(t *testing.T) {
|
||||
|
||||
t.Log("静态文件服务测试通过")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -111,7 +112,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))
|
||||
}
|
||||
|
||||
119
backend/cmd/versionctl/main.go
Normal file
119
backend/cmd/versionctl/main.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"nex/backend/pkg/projectversion"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return usageError()
|
||||
}
|
||||
|
||||
root, err := projectversion.FindRepoRoot(mustGetwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "print":
|
||||
version, readErr := projectversion.ReadString(root)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
case "sync":
|
||||
return projectversion.Sync(root)
|
||||
case "check":
|
||||
return projectversion.Check(root)
|
||||
case "verify-tag":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("verify-tag 需要一个 tag 参数")
|
||||
}
|
||||
return projectversion.VerifyTag(root, args[1])
|
||||
case "macos-plist":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("macos-plist 需要一个最低系统版本参数")
|
||||
}
|
||||
return printMacOSPlist(root, args[1])
|
||||
case "asset-name":
|
||||
return printAssetName(root, args[1:])
|
||||
default:
|
||||
return usageError()
|
||||
}
|
||||
}
|
||||
|
||||
func printMacOSPlist(root, minMacOSVersion string) error {
|
||||
version, err := projectversion.ReadString(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plist, err := projectversion.DesktopInfoPlist(version, minMacOSVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Print(plist)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printAssetName(root string, args []string) error {
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("asset-name 至少需要 kind 和 platform 参数")
|
||||
}
|
||||
|
||||
version, err := projectversion.ReadString(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "server":
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("server 资产命名需要 platform 和 arch 参数")
|
||||
}
|
||||
name, nameErr := projectversion.ServerAssetName(version, args[1], args[2])
|
||||
if nameErr != nil {
|
||||
return nameErr
|
||||
}
|
||||
fmt.Println(name)
|
||||
return nil
|
||||
case "desktop":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("desktop 资产命名只需要 platform 参数")
|
||||
}
|
||||
name, nameErr := projectversion.DesktopAssetName(version, args[1])
|
||||
if nameErr != nil {
|
||||
return nameErr
|
||||
}
|
||||
fmt.Println(name)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的资产类型 %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetwd() string {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return wd
|
||||
}
|
||||
|
||||
func usageError() error {
|
||||
return fmt.Errorf("用法: versionctl <print|sync|check|verify-tag|macos-plist|asset-name>")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
63
backend/internal/conversion/engine_adapter_test.go
Normal file
63
backend/internal/conversion/engine_adapter_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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, "参数无效")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 提取请求头
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
22
backend/pkg/buildinfo/buildinfo.go
Normal file
22
backend/pkg/buildinfo/buildinfo.go
Normal 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
|
||||
}
|
||||
17
backend/pkg/buildinfo/buildinfo_test.go
Normal file
17
backend/pkg/buildinfo/buildinfo_test.go
Normal 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() 不应为空")
|
||||
}
|
||||
}
|
||||
342
backend/pkg/projectversion/version.go
Normal file
342
backend/pkg/projectversion/version.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package projectversion
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const versionFileName = "VERSION"
|
||||
|
||||
var (
|
||||
semverRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
|
||||
packageVersionRegex = regexp.MustCompile(`(?m)^(\s*"version"\s*:\s*")([^"]+)(",?)$`)
|
||||
frontendVersionFiles = []string{
|
||||
"frontend/.env.production",
|
||||
"frontend/.env.development",
|
||||
"frontend/.env.desktop",
|
||||
}
|
||||
)
|
||||
|
||||
type Version struct {
|
||||
Major int
|
||||
Minor int
|
||||
Patch int
|
||||
}
|
||||
|
||||
func Parse(raw string) (Version, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
parts := semverRegex.FindStringSubmatch(trimmed)
|
||||
if parts == nil {
|
||||
return Version{}, fmt.Errorf("版本号 %q 不符合 x.y.z 格式", raw)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("解析 major 失败: %w", err)
|
||||
}
|
||||
|
||||
minor, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("解析 minor 失败: %w", err)
|
||||
}
|
||||
|
||||
patch, err := strconv.Atoi(parts[3])
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("解析 patch 失败: %w", err)
|
||||
}
|
||||
|
||||
return Version{Major: major, Minor: minor, Patch: patch}, nil
|
||||
}
|
||||
|
||||
func (v Version) String() string {
|
||||
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
func FindRepoRoot(start string) (string, error) {
|
||||
current := start
|
||||
for {
|
||||
workspacePath := filepath.Join(current, "go.work")
|
||||
if _, err := os.Stat(workspacePath); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", errors.New("未找到仓库根目录 go.work")
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func Read(root string) (Version, error) {
|
||||
content, err := os.ReadFile(filepath.Join(root, versionFileName))
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("读取 VERSION 失败: %w", err)
|
||||
}
|
||||
|
||||
return Parse(string(content))
|
||||
}
|
||||
|
||||
func ReadString(root string) (string, error) {
|
||||
version, err := Read(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return version.String(), nil
|
||||
}
|
||||
|
||||
func Sync(root string) error {
|
||||
version, err := ReadString(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
packageJSONPath := filepath.Join(root, "frontend", "package.json")
|
||||
packageJSONContent, err := os.ReadFile(packageJSONPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 frontend/package.json 失败: %w", err)
|
||||
}
|
||||
|
||||
updatedPackageJSON, err := UpdatePackageJSONVersion(string(packageJSONContent), version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(packageJSONPath, []byte(updatedPackageJSON), 0o644); err != nil {
|
||||
return fmt.Errorf("写入 frontend/package.json 失败: %w", err)
|
||||
}
|
||||
|
||||
for _, relPath := range frontendVersionFiles {
|
||||
fullPath := filepath.Join(root, relPath)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 %s 失败: %w", relPath, err)
|
||||
}
|
||||
|
||||
updated := UpsertEnvVar(string(content), "VITE_APP_VERSION", version)
|
||||
if err := os.WriteFile(fullPath, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("写入 %s 失败: %w", relPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Check(root string) error {
|
||||
version, err := ReadString(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
packageJSONPath := filepath.Join(root, "frontend", "package.json")
|
||||
packageJSONContent, err := os.ReadFile(packageJSONPath)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("读取 frontend/package.json 失败: %w", err))
|
||||
} else {
|
||||
actualVersion, readErr := ReadPackageJSONVersion(string(packageJSONContent))
|
||||
if readErr != nil {
|
||||
errs = append(errs, readErr)
|
||||
} else if actualVersion != version {
|
||||
errs = append(errs, fmt.Errorf("frontend/package.json 版本为 %s,期望 %s", actualVersion, version))
|
||||
}
|
||||
}
|
||||
|
||||
for _, relPath := range frontendVersionFiles {
|
||||
fullPath := filepath.Join(root, relPath)
|
||||
content, readErr := os.ReadFile(fullPath)
|
||||
if readErr != nil {
|
||||
errs = append(errs, fmt.Errorf("读取 %s 失败: %w", relPath, readErr))
|
||||
continue
|
||||
}
|
||||
|
||||
actualValue, ok := ReadEnvVar(string(content), "VITE_APP_VERSION")
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("%s 缺少 VITE_APP_VERSION", relPath))
|
||||
continue
|
||||
}
|
||||
|
||||
if actualValue != version {
|
||||
errs = append(errs, fmt.Errorf("%s 的 VITE_APP_VERSION 为 %s,期望 %s", relPath, actualValue, version))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func VerifyTag(root, tag string) error {
|
||||
version, err := ReadString(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(tag, "v") {
|
||||
return fmt.Errorf("tag %q 必须以 v 开头", tag)
|
||||
}
|
||||
|
||||
if tag[1:] != version {
|
||||
return fmt.Errorf("tag %q 与 VERSION %q 不一致", tag, version)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdatePackageJSONVersion(content, version string) (string, error) {
|
||||
if _, err := Parse(version); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !packageVersionRegex.MatchString(content) {
|
||||
return "", errors.New("frontend/package.json 缺少 version 字段")
|
||||
}
|
||||
|
||||
updated := packageVersionRegex.ReplaceAllString(content, `${1}`+version+`${3}`)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func ReadPackageJSONVersion(content string) (string, error) {
|
||||
parts := packageVersionRegex.FindStringSubmatch(content)
|
||||
if parts == nil {
|
||||
return "", errors.New("frontend/package.json 缺少 version 字段")
|
||||
}
|
||||
|
||||
if _, err := Parse(parts[2]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parts[2], nil
|
||||
}
|
||||
|
||||
func UpsertEnvVar(content, key, value string) string {
|
||||
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
|
||||
if len(lines) == 1 && lines[0] == "" {
|
||||
lines = lines[:0]
|
||||
}
|
||||
|
||||
updated := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, key+"=") {
|
||||
lines[i] = key + "=" + value
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
lines = append(lines, key+"="+value)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func ReadEnvVar(content, key string) (string, bool) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, key+"=") {
|
||||
return strings.TrimPrefix(line, key+"="), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ServerAssetName(version, goos, arch string) (string, error) {
|
||||
if _, err := Parse(version); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch goos {
|
||||
case "linux", "windows", "darwin":
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的 server 平台 %q", goos)
|
||||
}
|
||||
|
||||
if arch == "" {
|
||||
return "", errors.New("server 资产命名缺少架构")
|
||||
}
|
||||
|
||||
ext := ".tar.gz"
|
||||
if goos == "windows" {
|
||||
ext = ".zip"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("nex-server_%s_%s_%s%s", version, goos, arch, ext), nil
|
||||
}
|
||||
|
||||
func DesktopAssetName(version, platform string) (string, error) {
|
||||
if _, err := Parse(version); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch platform {
|
||||
case "linux":
|
||||
return fmt.Sprintf("Nex_%s_linux_amd64.tar.gz", version), nil
|
||||
case "windows":
|
||||
return fmt.Sprintf("Nex_%s_windows_amd64.zip", version), nil
|
||||
case "macos":
|
||||
return fmt.Sprintf("Nex_%s_macOS_universal.zip", version), nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的 desktop 平台 %q", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {
|
||||
if _, err := Parse(version); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(minMacOSVersion) == "" {
|
||||
return "", errors.New("min macOS version 不能为空")
|
||||
}
|
||||
|
||||
content := strings.Join([]string{
|
||||
`<?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>icon</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>` + version + `</string>`,
|
||||
` <key>CFBundleVersion</key>`,
|
||||
` <string>` + version + `</string>`,
|
||||
` <key>NSHumanReadableCopyright</key>`,
|
||||
` <string>Copyright © 2026 Nex</string>`,
|
||||
` <key>LSMinimumSystemVersion</key>`,
|
||||
` <string>` + minMacOSVersion + `</string>`,
|
||||
` <key>LSUIElement</key>`,
|
||||
` <true/>`,
|
||||
` <key>NSHighResolutionCapable</key>`,
|
||||
` <true/>`,
|
||||
`</dict>`,
|
||||
`</plist>`,
|
||||
}, "\n")
|
||||
|
||||
return content + "\n", nil
|
||||
}
|
||||
113
backend/pkg/projectversion/version_test.go
Normal file
113
backend/pkg/projectversion/version_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package projectversion
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
version, err := Parse("1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 3}, version)
|
||||
assert.Equal(t, "1.2.3", version.String())
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
invalidValues := []string{"", "1.2", "1.2.3.4", "v1.2.3", "01.2.3", "1.02.3"}
|
||||
for _, tc := range invalidValues {
|
||||
_, err := Parse(tc)
|
||||
assert.Error(t, err, "%q 应校验失败", tc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdatePackageJSONVersion(t *testing.T) {
|
||||
content := "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"
|
||||
updated, err := UpdatePackageJSONVersion(content, "1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, updated, `"version": "1.2.3"`)
|
||||
|
||||
version, err := ReadPackageJSONVersion(updated)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.2.3", version)
|
||||
}
|
||||
|
||||
func TestUpsertEnvVar(t *testing.T) {
|
||||
updated := UpsertEnvVar("VITE_API_BASE=/api\n", "VITE_APP_VERSION", "1.2.3")
|
||||
assert.Contains(t, updated, "VITE_API_BASE=/api\n")
|
||||
assert.Contains(t, updated, "VITE_APP_VERSION=1.2.3\n")
|
||||
|
||||
updated = UpsertEnvVar(updated, "VITE_APP_VERSION", "2.0.0")
|
||||
value, ok := ReadEnvVar(updated, "VITE_APP_VERSION")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "2.0.0", value)
|
||||
assert.Equal(t, 1, strings.Count(updated, "VITE_APP_VERSION="))
|
||||
}
|
||||
|
||||
func TestSyncAndCheck(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "frontend"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", "package.json"), []byte("{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.production"), []byte("VITE_API_BASE=/api\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.development"), []byte("VITE_API_BASE=\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.desktop"), []byte("VITE_API_BASE=\n"), 0o644))
|
||||
|
||||
require.NoError(t, Sync(root))
|
||||
require.NoError(t, Check(root))
|
||||
|
||||
packageJSONContent, err := os.ReadFile(filepath.Join(root, "frontend", "package.json"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(packageJSONContent), `"version": "1.2.3"`)
|
||||
|
||||
for _, relPath := range frontendVersionFiles {
|
||||
content, readErr := os.ReadFile(filepath.Join(root, relPath))
|
||||
require.NoError(t, readErr)
|
||||
assert.Contains(t, string(content), "VITE_APP_VERSION=1.2.3\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTag(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
|
||||
|
||||
require.NoError(t, VerifyTag(root, "v1.2.3"))
|
||||
assert.Error(t, VerifyTag(root, "1.2.3"))
|
||||
assert.Error(t, VerifyTag(root, "v1.2.4"))
|
||||
}
|
||||
|
||||
func TestAssetNames(t *testing.T) {
|
||||
linuxServer, err := ServerAssetName("1.2.3", "linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "nex-server_1.2.3_linux_amd64.tar.gz", linuxServer)
|
||||
|
||||
macServer, err := ServerAssetName("1.2.3", "darwin", "arm64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "nex-server_1.2.3_darwin_arm64.tar.gz", macServer)
|
||||
|
||||
macDesktop, err := DesktopAssetName("1.2.3", "macos")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop)
|
||||
|
||||
_, err = DesktopAssetName("1.2.3", "ios")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDesktopInfoPlist(t *testing.T) {
|
||||
plist, err := DesktopInfoPlist("1.2.3", "13.0")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, plist, "<key>CFBundleShortVersionString</key>\n <string>1.2.3</string>")
|
||||
assert.Contains(t, plist, "<key>CFBundleVersion</key>\n <string>1.2.3</string>")
|
||||
assert.Contains(t, plist, "<key>LSMinimumSystemVersion</key>\n <string>13.0</string>")
|
||||
|
||||
_, err = DesktopInfoPlist("1.2", "13.0")
|
||||
assert.Error(t, err)
|
||||
_, err = DesktopInfoPlist("1.2.3", "")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 字段测试 ============
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 protocol(URL 前缀 / 配置映射 / 任意方式) │ │
|
||||
│ │ 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.1),Decoder 来自 provider 协议(解码上游 SSE),Encoder 来自 client 协议(编码给客户端)
|
||||
- `buildHeaders`:每次请求出站时调用,同协议透传也会调用
|
||||
- `encodeError`:转换失败或 Middleware 中断时调用,使用 client 协议格式编码错误响应
|
||||
- `encodeError`:转换失败或 Middleware 中断时的协议错误编码能力;ProxyHandler 的网关层错误使用应用统一格式,不调用协议错误编码
|
||||
|
||||
### D.1 协议基本信息
|
||||
|
||||
|
||||
@@ -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. 请求头构建
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_APP_VERSION=0.1.0
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_APP_VERSION=0.1.0
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE=/api
|
||||
VITE_APP_VERSION=0.1.0
|
||||
|
||||
3
frontend/.vscode/extensions.json
vendored
3
frontend/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
7
frontend/.vscode/settings.json
vendored
7
frontend/.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
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'
|
||||
|
||||
const { MenuItem } = Menu
|
||||
@@ -14,6 +21,7 @@ export function AppLayout() {
|
||||
if (location.pathname === '/providers') return '供应商管理'
|
||||
if (location.pathname === '/stats') return '用量统计'
|
||||
if (location.pathname === '/settings') return '设置'
|
||||
if (location.pathname === '/about') return '关于'
|
||||
return 'AI Gateway'
|
||||
}
|
||||
|
||||
@@ -70,6 +78,9 @@ export function AppLayout() {
|
||||
<MenuItem value='/settings' icon={<SettingIcon />}>
|
||||
设置
|
||||
</MenuItem>
|
||||
<MenuItem value='/about' icon={<InfoCircleIcon />}>
|
||||
关于
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Layout.Aside>
|
||||
<Layout style={{ marginLeft: asideWidth }}>
|
||||
|
||||
30
frontend/src/pages/About/index.tsx
Normal file
30
frontend/src/pages/About/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Card } from 'tdesign-react'
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4rem 0',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>Nex</h1>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
|
||||
AI Gateway - 统一的大模型 API 网关
|
||||
</p>
|
||||
<a
|
||||
href='https://github.com/nex/gateway'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ marginTop: '1rem', color: 'var(--td-brand-color)' }}
|
||||
>
|
||||
https://github.com/nex/gateway
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
26
openspec/specs/about-page/spec.md
Normal file
26
openspec/specs/about-page/spec.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 关于页面
|
||||
|
||||
## Purpose
|
||||
|
||||
TBD - 提供关于页面展示项目品牌信息
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 关于页面
|
||||
|
||||
前端 SHALL 提供关于页面,使用 TDesign Card 组件居中展示项目品牌信息(应用名称、描述、项目链接)。
|
||||
|
||||
#### 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 设置 `bordered={false}`
|
||||
- **THEN** 内容 SHALL 居中展示
|
||||
@@ -13,22 +13,24 @@
|
||||
#### Scenario: 成功的非流式请求
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式)
|
||||
- **THEN** 网关 SHALL 剥离 `/anthropic` 前缀并将 `/v1/messages` 作为 Anthropic nativePath
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式
|
||||
- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式
|
||||
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
|
||||
- **THEN** 若上游返回 2xx,网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
|
||||
|
||||
#### Scenario: 成功的流式请求
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true`
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
|
||||
- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
|
||||
- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式,或在同协议 raw passthrough 下透传 Anthropic SSE
|
||||
- **THEN** 网关 SHALL 使用 `event: <type>\ndata: <json>\n\n` 格式流式返回给应用
|
||||
|
||||
#### Scenario: 同协议透传(Anthropic → Anthropic Provider)
|
||||
|
||||
- **WHEN** 客户端使用 Anthropic 协议且目标供应商也是 Anthropic 协议
|
||||
- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发
|
||||
- **THEN** 请求和响应 Body SHALL 保持原样
|
||||
- **THEN** 网关 SHALL 跳过 Canonical 转换
|
||||
- **THEN** 网关 SHALL 使用 Anthropic adapter 重建上游 URL 和认证 Header
|
||||
- **THEN** 若请求使用统一模型 ID,网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
|
||||
|
||||
### Requirement: 双向协议转换
|
||||
|
||||
@@ -39,7 +41,7 @@
|
||||
- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议
|
||||
- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest
|
||||
- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest
|
||||
- **THEN** SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
|
||||
- **THEN** 若上游返回 2xx,SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
|
||||
- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse
|
||||
|
||||
#### Scenario: OpenAI 客户端 → Anthropic 供应商
|
||||
@@ -47,12 +49,18 @@
|
||||
- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议
|
||||
- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest
|
||||
- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest
|
||||
- **THEN** SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
|
||||
- **THEN** 若上游返回 2xx,SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
|
||||
- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse
|
||||
|
||||
#### Scenario: 上游错误透传
|
||||
|
||||
- **WHEN** Anthropic 代理请求收到上游非 2xx HTTP 响应
|
||||
- **THEN** 网关 SHALL 直接透传上游 status code、过滤后的 headers 和 body
|
||||
- **THEN** 网关 SHALL NOT 将上游错误转换为 Anthropic 错误格式
|
||||
|
||||
### Requirement: Anthropic 端点保持 v1 层级
|
||||
|
||||
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以兼容 Claude Code。
|
||||
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以匹配 Anthropic 原生路径约定。
|
||||
|
||||
#### Scenario: Claude Code 调用 Anthropic 端点
|
||||
|
||||
@@ -60,3 +68,17 @@ Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以兼容 Claude Code
|
||||
- **THEN** 网关 SHALL 正确处理请求
|
||||
- **THEN** nativePath SHALL 为 `/v1/messages`
|
||||
- **THEN** 最终上游 URL SHALL 为 `base_url + /v1/messages`
|
||||
|
||||
### Requirement: Anthropic 上游路径映射
|
||||
|
||||
Anthropic adapter SHALL 将剥离协议前缀后的 Anthropic nativePath 映射为 Anthropic 上游路径。
|
||||
|
||||
#### Scenario: Messages 上游路径
|
||||
|
||||
- **WHEN** nativePath 为 `/v1/messages`
|
||||
- **THEN** Anthropic adapter BuildUrl SHALL 返回 `/v1/messages`
|
||||
|
||||
#### Scenario: Models 上游路径
|
||||
|
||||
- **WHEN** nativePath 为 `/v1/models`
|
||||
- **THEN** Anthropic adapter BuildUrl SHALL 返回 `/v1/models`
|
||||
|
||||
@@ -325,3 +325,137 @@ TargetProvider 的 ModelName 字段 SHALL 存储上游供应商的模型名称
|
||||
|
||||
- **WHEN** 协议适配器编码请求时
|
||||
- **THEN** SHALL 使用 `TargetProvider.ModelName` 作为发给上游的 `model` 字段值(值为路由结果中的 model_name)
|
||||
|
||||
### Requirement: 同协议请求 URL 使用 Adapter 映射
|
||||
|
||||
ConversionEngine SHALL 在同协议透传和 Smart Passthrough 场景下使用 providerAdapter.BuildUrl 构建上游 URL 路径。
|
||||
|
||||
#### Scenario: 同协议 Chat URL 映射
|
||||
|
||||
- **WHEN** clientProtocol == providerProtocol 且 interfaceType 为 CHAT
|
||||
- **THEN** ConversionEngine SHALL 调用 providerAdapter.BuildUrl(nativePath, interfaceType)
|
||||
- **THEN** 上游 URL SHALL 为 provider.BaseURL 与 BuildUrl 返回路径的组合
|
||||
- **THEN** ConversionEngine SHALL NOT 直接将 provider.BaseURL 与 nativePath 拼接为上游 URL
|
||||
|
||||
#### Scenario: 未知接口 URL 映射
|
||||
|
||||
- **WHEN** interfaceType 为 PASSTHROUGH
|
||||
- **THEN** providerAdapter.BuildUrl SHALL 返回适合目标协议的路径或原 nativePath
|
||||
- **THEN** ConversionEngine SHALL 使用该路径构建上游 URL
|
||||
|
||||
### Requirement: 上游 URL 构建使用目标协议 Adapter
|
||||
|
||||
ConversionEngine SHALL 始终使用目标供应商协议的 adapter 构建上游 URL 路径,避免客户端协议 nativePath 中的版本段泄露到目标协议上游 URL。
|
||||
|
||||
#### Scenario: OpenAI 客户端到 OpenAI 供应商
|
||||
|
||||
- **WHEN** clientProtocol 为 `openai`,providerProtocol 为 `openai`,nativePath 为 `/v1/chat/completions`
|
||||
- **THEN** ConversionEngine SHALL 调用 OpenAI adapter 的 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||
- **THEN** 上游 path SHALL 为 `/chat/completions`
|
||||
- **THEN** 当 provider.base_url 为 `https://api.openai.com/v1` 时,最终上游 URL SHALL 为 `https://api.openai.com/v1/chat/completions`
|
||||
|
||||
#### Scenario: OpenAI 客户端到 Anthropic 供应商
|
||||
|
||||
- **WHEN** clientProtocol 为 `openai`,providerProtocol 为 `anthropic`,nativePath 为 `/v1/chat/completions`
|
||||
- **THEN** ConversionEngine SHALL 使用 OpenAI adapter 识别接口类型为 `InterfaceTypeChat`
|
||||
- **THEN** ConversionEngine SHALL 调用 Anthropic adapter 的 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||
- **THEN** 上游 path SHALL 为 `/v1/messages`
|
||||
- **THEN** OpenAI nativePath 中的 `/v1/chat/completions` SHALL NOT 被直接拼接到 Anthropic 上游 URL
|
||||
|
||||
#### Scenario: Anthropic 客户端到 OpenAI 供应商
|
||||
|
||||
- **WHEN** clientProtocol 为 `anthropic`,providerProtocol 为 `openai`,nativePath 为 `/v1/messages`
|
||||
- **THEN** ConversionEngine SHALL 使用 Anthropic adapter 识别接口类型为 `InterfaceTypeChat`
|
||||
- **THEN** ConversionEngine SHALL 调用 OpenAI adapter 的 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||
- **THEN** 上游 path SHALL 为 `/chat/completions`
|
||||
- **THEN** 当 provider.base_url 为 `https://api.openai.com/v1` 时,最终上游 URL SHALL 为 `https://api.openai.com/v1/chat/completions`
|
||||
|
||||
### Requirement: SSE Frame 级 Smart Passthrough
|
||||
|
||||
系统 SHALL 支持同协议流式 Smart Passthrough 对 SSE frame 中的 model 字段进行最小化改写。
|
||||
|
||||
#### Scenario: 改写 SSE data JSON
|
||||
|
||||
- **WHEN** 同协议流式响应需要将上游模型名改写为统一模型 ID
|
||||
- **THEN** 系统 SHALL 按 SSE frame 解析上游字节流
|
||||
- **THEN** 对包含 JSON payload 的 `data` 行 SHALL 调用 adapter.RewriteResponseModelName 改写 model 字段
|
||||
- **THEN** SHALL 重建合法 SSE frame 输出
|
||||
|
||||
#### Scenario: 保留 DONE 事件
|
||||
|
||||
- **WHEN** SSE frame 的 data payload 为 `[DONE]`
|
||||
- **THEN** 系统 SHALL 原样输出 `[DONE]`
|
||||
- **THEN** SHALL NOT 尝试按 JSON 解析
|
||||
|
||||
#### Scenario: 改写失败宽容降级
|
||||
|
||||
- **WHEN** SSE frame 解析或 model 改写失败
|
||||
- **THEN** 系统 SHALL 记录 warn 日志
|
||||
- **THEN** SHALL 输出原始 SSE frame
|
||||
- **THEN** SHALL 继续处理后续 frame
|
||||
|
||||
### Requirement: Adapter 模型提取边界
|
||||
|
||||
ProtocolAdapter SHALL 只对明确适配的接口提供 model 提取和 model 改写能力。
|
||||
|
||||
#### Scenario: 已适配接口提取 model
|
||||
|
||||
- **WHEN** ifaceType 为 adapter 明确支持提取 model 的接口
|
||||
- **THEN** ExtractModelName SHALL 按该协议和接口的请求格式提取 model 字段
|
||||
|
||||
#### Scenario: 未适配接口不提取 model
|
||||
|
||||
- **WHEN** ifaceType 为 PASSTHROUGH 或 adapter 未明确支持提取 model 的接口
|
||||
- **THEN** ExtractModelName SHALL 返回错误或空结果
|
||||
- **THEN** 调用方 SHALL 按无 model 请求处理
|
||||
|
||||
### Requirement: 跨协议多模态暂不支持
|
||||
|
||||
ConversionEngine SHALL 在跨协议完整转换中对当前暂不支持的多模态内容返回明确错误。
|
||||
|
||||
#### Scenario: 跨协议请求包含多模态内容块
|
||||
|
||||
- **WHEN** clientProtocol != providerProtocol 且 CanonicalRequest 中包含 image、audio、video 或 file 内容块
|
||||
- **THEN** ConversionEngine SHALL 中断转换
|
||||
- **THEN** SHALL 返回网关层 `UNSUPPORTED_MULTIMODAL` 错误
|
||||
- **THEN** SHALL NOT 静默丢弃多模态内容
|
||||
|
||||
#### Scenario: 同协议多模态请求
|
||||
|
||||
- **WHEN** clientProtocol == providerProtocol 且请求通过 Smart Passthrough 或 raw passthrough 处理
|
||||
- **THEN** 系统 SHALL 保留原始请求体中未改写字段
|
||||
- **THEN** SHALL NOT 因多模态字段存在而执行跨协议多模态校验
|
||||
|
||||
### Requirement: 上游非 2xx 响应不进入转换
|
||||
|
||||
ConversionEngine SHALL 只转换调用方传入的成功响应,ProxyHandler SHALL 在调用转换前过滤上游非 2xx 响应。
|
||||
|
||||
#### Scenario: 非 2xx 响应绕过响应转换
|
||||
|
||||
- **WHEN** 上游响应状态码不是 2xx
|
||||
- **THEN** ProxyHandler SHALL NOT 调用 ConversionEngine.ConvertHttpResponse
|
||||
- **THEN** ProxyHandler SHALL 直接透传该响应
|
||||
|
||||
#### Scenario: 流式非 2xx 响应绕过流式转换
|
||||
|
||||
- **WHEN** 流式请求收到上游非 2xx 响应
|
||||
- **THEN** ProxyHandler SHALL NOT 调用 ConversionEngine.CreateStreamConverter
|
||||
- **THEN** ProxyHandler SHALL 直接透传该响应
|
||||
|
||||
### Requirement: 协议路径来源
|
||||
|
||||
ProtocolAdapter SHALL 以对应协议的本地 API reference 文档作为 URL 识别和 URL 映射的事实来源。
|
||||
|
||||
#### Scenario: OpenAI 路径来源
|
||||
|
||||
- **WHEN** 实现或测试 OpenAI adapter 的 DetectInterfaceType 或 BuildUrl
|
||||
- **THEN** SHALL 参考 `docs/api_reference/openai` 中的接口路径
|
||||
- **THEN** SHALL 忽略 `docs/api_reference/openai/responses` 目录
|
||||
- **THEN** SHALL NOT 因其他协议包含 `/v1` 而给 OpenAI nativePath 添加 `/v1`
|
||||
|
||||
#### Scenario: Anthropic 路径来源
|
||||
|
||||
- **WHEN** 实现或测试 Anthropic adapter 的 DetectInterfaceType 或 BuildUrl
|
||||
- **THEN** SHALL 参考 `docs/api_reference/anthropic` 中的接口路径
|
||||
- **THEN** SHALL 保留文档中接口路径自带的 `/v1` 前缀
|
||||
- **THEN** SHALL NOT 因 OpenAI nativePath 不含 `/v1` 而移除 Anthropic nativePath 中的 `/v1`
|
||||
|
||||
@@ -67,11 +67,11 @@
|
||||
|
||||
### Requirement: 迁移命令集成
|
||||
|
||||
迁移 SHALL 集成到 Makefile。
|
||||
迁移 SHALL 集成到 `backend/Makefile`,而不是根目录 `Makefile`。
|
||||
|
||||
#### Scenario: 迁移 up 命令
|
||||
|
||||
- **WHEN** 执行 `make backend-migrate-up`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make migrate-up`
|
||||
- **THEN** SHALL 执行所有待执行的迁移
|
||||
- **THEN** SHALL 使用 `DB_DRIVER` 变量选择方言目录(默认 `sqlite3`)
|
||||
- **THEN** SHALL 使用 `DB_DSN` 变量作为数据库连接串
|
||||
@@ -79,20 +79,20 @@
|
||||
|
||||
#### Scenario: 迁移 down 命令
|
||||
|
||||
- **WHEN** 执行 `make backend-migrate-down`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make migrate-down`
|
||||
- **THEN** SHALL 回滚最后一个迁移
|
||||
- **THEN** SHALL 使用 `DB_DRIVER` 和 `DB_DSN` 变量
|
||||
- **THEN** SHALL 显示回滚进度
|
||||
|
||||
#### Scenario: 迁移状态命令
|
||||
|
||||
- **WHEN** 执行 `make backend-migrate-status`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make migrate-status`
|
||||
- **THEN** SHALL 显示当前迁移状态
|
||||
- **THEN** SHALL 显示已执行和待执行的迁移
|
||||
|
||||
#### Scenario: 创建迁移命令
|
||||
|
||||
- **WHEN** 执行 `make backend-migrate-create`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make migrate-create`
|
||||
- **THEN** SHALL 同时在 `migrations/sqlite/` 和 `migrations/mysql/` 两个目录创建新的迁移文件模板
|
||||
- **THEN** SHALL 使用递增的版本号
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **AND** 菜单包含"打开管理界面"选项
|
||||
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
|
||||
- **AND** 菜单包含"关于"选项
|
||||
- **AND** 菜单包含"退出"选项
|
||||
|
||||
#### Scenario: 打开管理界面
|
||||
@@ -74,21 +73,46 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 静态文件服务
|
||||
|
||||
系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。
|
||||
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
|
||||
|
||||
#### Scenario: API 请求路由
|
||||
|
||||
- **WHEN** 请求路径以 `/api/` 或 `/v1/` 开头
|
||||
- **THEN** 请求由现有业务 handler 处理
|
||||
- **WHEN** 请求路径以 `/api/` 或 `/health` 开头
|
||||
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
|
||||
|
||||
#### 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: Favicon 路由
|
||||
|
||||
- **WHEN** 请求路径为 `/favicon.svg`
|
||||
- **THEN** 返回嵌入的前端 favicon 资源
|
||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||
|
||||
#### Scenario: SPA 路由回退
|
||||
|
||||
- **WHEN** 请求路径不匹配任何 API 或静态资源路由
|
||||
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
|
||||
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
|
||||
|
||||
### Requirement: 端口冲突检测
|
||||
@@ -108,51 +132,53 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 跨平台构建
|
||||
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,中间构建产物文件名 SHALL 保持 `nex-{os}-{arch}[.exe]` 格式,最终桌面发布资产文件名 SHALL 包含统一版本号和平台标识。
|
||||
|
||||
#### Scenario: macOS 构建
|
||||
|
||||
- **WHEN** 执行 `desktop-build-mac` 构建命令
|
||||
- **WHEN** 执行 `desktop-build-mac` 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件
|
||||
- **AND** 可打包为 `.app` bundle
|
||||
- **AND** 系统生成可打包为 `.app` bundle 的 macOS 桌面产物
|
||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS` 平台标识
|
||||
|
||||
#### Scenario: Windows 构建
|
||||
|
||||
- **WHEN** 执行 `desktop-build-win` 构建命令
|
||||
- **THEN** 生成 `nex-win-amd64.exe` 可执行文件
|
||||
- **WHEN** 执行 `desktop-build-win` 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统生成 Windows 桌面可执行文件
|
||||
- **AND** 生成 `nex-win-amd64.exe` 可执行文件
|
||||
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `windows` 平台标识
|
||||
|
||||
#### Scenario: Linux 构建
|
||||
|
||||
- **WHEN** 执行 `desktop-build-linux` 构建命令
|
||||
- **THEN** 生成 `nex-linux-amd64` 可执行文件
|
||||
- **WHEN** 执行 `desktop-build-linux` 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统生成 Linux 桌面可执行文件
|
||||
- **AND** 生成 `nex-linux-amd64` 可执行文件
|
||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `linux` 平台标识
|
||||
|
||||
### Requirement: macOS .app 打包
|
||||
|
||||
系统 SHALL 支持打包为 macOS .app bundle。
|
||||
系统 SHALL 支持打包为 macOS `.app` bundle,并使 bundle 元数据中的版本字段来源于统一版本号而非硬编码值。
|
||||
|
||||
#### Scenario: .app 结构
|
||||
|
||||
- **WHEN** 执行打包脚本
|
||||
- **WHEN** 执行 macOS 桌面打包脚本
|
||||
- **THEN** 生成 `Nex.app` 目录结构
|
||||
- **AND** 包含 `Contents/Info.plist` 元数据
|
||||
- **AND** 包含 `Contents/MacOS/nex` 可执行文件
|
||||
- **AND** 包含 `Contents/Resources/icon.icns` 图标
|
||||
- **AND** `Info.plist` 中 `LSUIElement` 为 `true`(不显示 Dock 图标)
|
||||
|
||||
### Requirement: 关于对话框
|
||||
#### Scenario: bundle 版本元数据同步
|
||||
|
||||
系统 SHALL 提供关于对话框显示应用信息。在 Windows 上 SHALL 使用 `user32.dll` 的 `MessageBoxW` API 实现。
|
||||
|
||||
#### Scenario: 显示关于
|
||||
|
||||
- **WHEN** 用户点击托盘菜单"关于"
|
||||
- **THEN** 显示对话框包含应用名称、项目链接
|
||||
- **AND** 在 Windows 上使用 `MessageBoxW` 原生对话框实现
|
||||
- **WHEN** 当前统一版本号为 `1.2.3`
|
||||
- **THEN** `Info.plist` 中 `CFBundleShortVersionString` SHALL 为 `1.2.3`
|
||||
- **AND** `Info.plist` 中 `CFBundleVersion` SHALL 为 `1.2.3`
|
||||
- **AND** 打包流程 SHALL NOT 使用硬编码版本值
|
||||
|
||||
### Requirement: Windows 原生对话框
|
||||
|
||||
系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误和关于对话框,替代 `msg *` 命令。
|
||||
系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。
|
||||
|
||||
#### Scenario: 错误提示对话框
|
||||
|
||||
@@ -162,18 +188,10 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **AND** 对话框包含错误描述文本
|
||||
- **AND** 对话框显示错误图标(MB_ICONERROR)
|
||||
|
||||
#### Scenario: 关于对话框
|
||||
|
||||
- **WHEN** 用户在 Windows 上点击托盘菜单"关于"
|
||||
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
||||
- **AND** 对话框标题栏显示"关于 Nex Gateway"
|
||||
- **AND** 对话框包含应用信息文本
|
||||
- **AND** 对话框显示信息图标(MB_ICONINFORMATION)
|
||||
|
||||
#### Scenario: 非 Windows 平台不受影响
|
||||
|
||||
- **WHEN** 应用运行在 macOS 或 Linux 上
|
||||
- **THEN** 错误和关于对话框仍使用平台原有实现(osascript / zenity)
|
||||
- **THEN** 错误对话框仍使用平台原有实现(osascript / zenity)
|
||||
|
||||
### Requirement: Linux 对话框降级策略
|
||||
|
||||
|
||||
@@ -23,6 +23,70 @@
|
||||
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
||||
- **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选)
|
||||
|
||||
### Requirement: 网关层代理错误使用应用统一格式
|
||||
|
||||
系统 SHALL 对代理接口中由网关自身产生的错误使用应用统一错误响应格式。
|
||||
|
||||
#### Scenario: 标准网关错误格式
|
||||
|
||||
- **WHEN** 代理接口返回网关层错误
|
||||
- **THEN** SHALL 使用以下 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"error": "错误描述",
|
||||
"code": "ERROR_CODE"
|
||||
}
|
||||
```
|
||||
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
||||
- **THEN** `code` 字段 SHALL 包含机器可读的错误码
|
||||
|
||||
#### Scenario: 网关错误码集合
|
||||
|
||||
- **WHEN** 代理接口返回网关层错误
|
||||
- **THEN** code SHALL 使用以下枚举之一:`INVALID_JSON`、`INVALID_REQUEST`、`INVALID_MODEL_ID`、`MODEL_NOT_FOUND`、`PROVIDER_NOT_FOUND`、`UNSUPPORTED_INTERFACE`、`UNSUPPORTED_MULTIMODAL`、`CONVERSION_FAILED`、`UPSTREAM_UNAVAILABLE`
|
||||
|
||||
### Requirement: 代理接口上游错误透传
|
||||
|
||||
系统 SHALL 对代理接口中已经收到的上游 HTTP 错误响应执行透明透传。
|
||||
|
||||
#### Scenario: 非流式上游非 2xx 响应
|
||||
|
||||
- **WHEN** 非流式代理请求收到上游 HTTP 响应且状态码不是 2xx
|
||||
- **THEN** SHALL 透传上游 status code
|
||||
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
||||
- **THEN** SHALL 透传上游 body
|
||||
- **THEN** SHALL NOT 将上游错误包装为应用统一错误
|
||||
- **THEN** SHALL NOT 将上游错误转换为客户端协议错误格式
|
||||
|
||||
#### Scenario: 流式上游非 2xx 响应
|
||||
|
||||
- **WHEN** 流式代理请求收到上游 HTTP 响应且状态码不是 2xx
|
||||
- **THEN** SHALL 透传上游 status code
|
||||
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
||||
- **THEN** SHALL 透传上游 body
|
||||
- **THEN** SHALL NOT 创建 StreamConverter
|
||||
|
||||
### Requirement: 上游不可达错误
|
||||
|
||||
系统 SHALL 在没有收到上游 HTTP 响应时返回网关层错误。
|
||||
|
||||
#### Scenario: 上游连接失败
|
||||
|
||||
- **WHEN** ProviderClient 因 DNS、连接失败、TLS、超时或上下文取消等原因无法获得上游 HTTP 响应
|
||||
- **THEN** SHALL 返回 HTTP 502 或合适的 5xx 状态码
|
||||
- **THEN** SHALL 返回应用统一错误格式
|
||||
- **THEN** code SHALL 为 `UPSTREAM_UNAVAILABLE`
|
||||
|
||||
### Requirement: Hop-by-hop header 过滤
|
||||
|
||||
系统 SHALL 在透传上游错误响应时过滤 hop-by-hop headers。
|
||||
|
||||
#### Scenario: 过滤连接级 header
|
||||
|
||||
- **WHEN** 透传上游错误响应 headers
|
||||
- **THEN** SHALL 过滤 `Connection`、`Transfer-Encoding`、`Keep-Alive`、`Proxy-Authenticate`、`Proxy-Authorization`、`TE`、`Trailer`、`Upgrade`
|
||||
- **THEN** SHALL 保留 `Content-Type` 等普通响应 header
|
||||
|
||||
### Requirement: 前端提取并处理错误码
|
||||
|
||||
前端 SHALL 提取后端结构化错误响应中的错误码并用于错误处理。
|
||||
@@ -212,7 +276,7 @@
|
||||
|
||||
#### Scenario: 请求体 JSON 格式错误
|
||||
|
||||
- **WHEN** 代理请求的请求体不是有效的 JSON 格式
|
||||
- **WHEN** 代理请求的请求体不是有效的 JSON 格式,且该接口需要网关解析请求体
|
||||
- **THEN** SHALL 返回 HTTP 400 Bad Request
|
||||
- **THEN** SHALL 返回以下 JSON 格式:
|
||||
```json
|
||||
|
||||
@@ -409,7 +409,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 渲染侧边栏
|
||||
- **THEN** 侧边栏顶部 SHALL 显示应用名称/Logo
|
||||
- **THEN** 侧边栏 SHALL 包含导航菜单
|
||||
- **THEN** 导航菜单项 SHALL 包含:供应商管理(ServerIcon 图标)、用量统计(ChartLineIcon 图标)、设置(SettingIcon 图标)
|
||||
- **THEN** 导航菜单项 SHALL 包含:供应商管理(ServerIcon 图标)、用量统计(ChartLineIcon 图标)、设置(SettingIcon 图标)、关于(InfoCircleIcon 图标)
|
||||
|
||||
#### Scenario: 导航菜单交互
|
||||
|
||||
@@ -418,7 +418,9 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 用户点击导航中的"用量统计"
|
||||
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"设置"
|
||||
- **THEN** 前端 SHALL 导航到 \`/settings\` 并高亮当前菜单项
|
||||
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"关于"
|
||||
- **THEN** 前端 SHALL 导航到 `/about` 并高亮当前菜单项
|
||||
|
||||
### Requirement: 提供导航
|
||||
|
||||
@@ -430,6 +432,8 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式(BrowserRouter)
|
||||
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面
|
||||
- **THEN** `/stats` 路径 SHALL 显示用量统计页面
|
||||
- **THEN** `/settings` 路径 SHALL 显示设置页面
|
||||
- **THEN** `/about` 路径 SHALL 显示关于页面
|
||||
- **THEN** `/` 路径 SHALL 重定向到 `/providers`
|
||||
- **THEN** 不存在的路径 SHALL 显示 404 页面
|
||||
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
|
||||
### Requirement: MySQL 测试环境可启动
|
||||
|
||||
系统 SHALL 提供 Docker Compose 配置以启动 MySQL 8.0 测试环境。
|
||||
系统 SHALL 提供 Docker Compose 配置和 backend 局部 make 命令以启动 MySQL 8.0 测试环境。
|
||||
|
||||
#### Scenario: 启动 MySQL 测试容器
|
||||
- **WHEN** 执行 `make test-mysql-up`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make mysql-up`
|
||||
- **THEN** 启动 MySQL 8.0 容器,端口 13306
|
||||
- **AND** 创建数据库 `nex_test`
|
||||
- **AND** 容器数据存储在内存盘(tmpfs)
|
||||
|
||||
#### Scenario: 销毁 MySQL 测试容器
|
||||
- **WHEN** 执行 `make test-mysql-down`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make mysql-down`
|
||||
- **THEN** 停止并删除容器
|
||||
- **AND** 所有数据被销毁
|
||||
|
||||
@@ -90,15 +90,15 @@ MySQL 测试 SHALL 验证并发写入不丢失数据。
|
||||
|
||||
### Requirement: MySQL 测试命令完整
|
||||
|
||||
Makefile SHALL 提供完整的 MySQL 测试命令。
|
||||
`backend/Makefile` SHALL 提供完整的 MySQL 测试命令。
|
||||
|
||||
#### Scenario: 完整测试流程
|
||||
- **WHEN** 执行 `make test-mysql`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make mysql-test`
|
||||
- **THEN** 启动 Docker MySQL
|
||||
- **AND** 等待 MySQL 就绪
|
||||
- **AND** 运行所有 MySQL 测试
|
||||
- **AND** 销毁容器
|
||||
|
||||
#### Scenario: 快速测试(容器已运行)
|
||||
- **WHEN** 执行 `make test-mysql-quick`
|
||||
- **WHEN** 在 `backend/` 目录执行 `make mysql-test-quick`
|
||||
- **THEN** 直接运行测试,不管理容器生命周期
|
||||
|
||||
@@ -2,33 +2,66 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 OpenAI Chat Completions API 端点的协议代理行为,包括请求处理流程、模型路由、同协议透传和跨协议转换。
|
||||
定义 OpenAI Chat Completions API 端点及扩展层端点的协议代理行为,包括请求处理流程、模型路由、同协议透传和跨协议转换。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 支持 OpenAI 扩展层 v1 端点
|
||||
|
||||
网关 SHALL 提供带 `/v1` 层级的 OpenAI 扩展层端点供外部应用调用。
|
||||
|
||||
#### Scenario: Models 列表端点
|
||||
|
||||
- **WHEN** 应用发送 GET 请求到 `/openai/v1/models`
|
||||
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/models` 作为 OpenAI nativePath
|
||||
- **THEN** 网关 SHALL 返回 OpenAI Models 格式响应
|
||||
|
||||
#### Scenario: ModelInfo 详情端点
|
||||
|
||||
- **WHEN** 应用发送 GET 请求到 `/openai/v1/models/{provider_id}/{model_name}`
|
||||
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/models/{provider_id}/{model_name}` 作为 OpenAI nativePath
|
||||
- **THEN** 网关 SHALL 返回 OpenAI ModelInfo 格式响应
|
||||
|
||||
#### Scenario: Embeddings 端点
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/v1/embeddings`
|
||||
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/embeddings` 作为 OpenAI nativePath
|
||||
- **THEN** 网关 SHALL 使用 OpenAI adapter 识别并处理 Embeddings 请求
|
||||
|
||||
#### Scenario: Rerank 端点
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/v1/rerank`
|
||||
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/rerank` 作为 OpenAI nativePath
|
||||
- **THEN** 网关 SHALL 使用 OpenAI adapter 识别并处理 Rerank 请求
|
||||
|
||||
### Requirement: 支持 OpenAI Chat Completions API 端点
|
||||
|
||||
网关 SHALL 提供 OpenAI Chat Completions API 端点供外部应用调用。
|
||||
网关 SHALL 提供带 `/v1` 层级的 OpenAI Chat Completions API 端点供外部应用调用。
|
||||
|
||||
#### Scenario: 成功的非流式请求
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
||||
- **THEN** 网关 SHALL 剥离 `/openai` 前缀并将 `/v1/chat/completions` 作为 OpenAI nativePath
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求
|
||||
- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商
|
||||
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
|
||||
- **THEN** 若上游返回 2xx,网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
|
||||
|
||||
#### Scenario: 成功的流式请求
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带 `stream: true`
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带 `stream: true`
|
||||
- **THEN** 网关 SHALL 剥离 `/openai` 前缀并将 `/v1/chat/completions` 作为 OpenAI nativePath
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
|
||||
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用
|
||||
- **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]`
|
||||
- **THEN** 网关 SHALL 在 OpenAI 协议流完成时发送或透传 `data: [DONE]`
|
||||
|
||||
#### Scenario: 同协议透传(OpenAI → OpenAI Provider)
|
||||
|
||||
- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议
|
||||
- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发
|
||||
- **THEN** 请求和响应 Body SHALL 保持原样
|
||||
- **THEN** 网关 SHALL 跳过 Canonical 转换
|
||||
- **THEN** 网关 SHALL 使用 OpenAI adapter 重建上游 URL 和认证 Header
|
||||
- **THEN** 若请求使用统一模型 ID,网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
|
||||
- **THEN** OpenAI adapter SHALL 将 `/v1/chat/completions` 映射为上游路径 `/chat/completions`
|
||||
- **THEN** OpenAI 供应商 `base_url` 配置到版本路径一级时,最终上游 URL SHALL 不重复 `/v1`
|
||||
|
||||
### Requirement: 根据模型名称路由请求
|
||||
|
||||
@@ -36,20 +69,25 @@
|
||||
|
||||
#### Scenario: 有效模型路由
|
||||
|
||||
- **WHEN** 请求包含存在于配置模型中的 `model` 字段
|
||||
- **AND** 该模型已启用
|
||||
- **WHEN** 请求包含有效统一模型 ID 格式的 `model` 字段
|
||||
- **AND** 该模型存在且已启用
|
||||
- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商
|
||||
- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol
|
||||
|
||||
#### Scenario: 模型未找到
|
||||
|
||||
- **WHEN** 请求包含不存在于配置模型中的 `model` 字段
|
||||
- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应
|
||||
- **WHEN** 请求包含有效统一模型 ID 格式的 `model` 字段但模型不存在
|
||||
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
|
||||
|
||||
#### Scenario: 模型已禁用
|
||||
|
||||
- **WHEN** 请求包含已禁用模型的 `model` 字段
|
||||
- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应
|
||||
- **WHEN** 请求包含已禁用模型的有效统一模型 ID
|
||||
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
|
||||
|
||||
#### Scenario: 原始模型名兼容透传
|
||||
|
||||
- **WHEN** 请求中的 `model` 字段不是有效统一模型 ID 格式
|
||||
- **THEN** 网关 SHALL 按无可路由 model 请求走 forwardPassthrough
|
||||
|
||||
### Requirement: 跨协议请求转换
|
||||
|
||||
@@ -60,8 +98,16 @@
|
||||
- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式
|
||||
- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header
|
||||
- **THEN** 目标协议 Adapter SHALL 基于接口类型构建目标协议路径,不直接复用 OpenAI nativePath 中的 `/v1` 版本段
|
||||
|
||||
#### Scenario: 扩展层接口代理
|
||||
|
||||
- **WHEN** 网关收到 `/openai/v1/models` 等 GET 请求
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 转换扩展层接口的响应格式
|
||||
- **THEN** 网关 SHALL 将 `/v1/models` 作为 OpenAI nativePath 识别扩展层接口
|
||||
- **THEN** 网关 SHALL 通过本地聚合或 ConversionEngine 转换扩展层接口响应格式
|
||||
|
||||
#### Scenario: 上游错误透传
|
||||
|
||||
- **WHEN** OpenAI 代理请求收到上游非 2xx HTTP 响应
|
||||
- **THEN** 网关 SHALL 直接透传上游 status code、过滤后的 headers 和 body
|
||||
- **THEN** 网关 SHALL NOT 将上游错误转换为 OpenAI 错误格式
|
||||
|
||||
@@ -239,6 +239,26 @@ Decoder 几乎 1:1 映射,维护最小状态机:
|
||||
- **WHEN** delta.type == "thinking_delta"
|
||||
- **THEN** SHALL 编码为 Anthropic thinking_delta
|
||||
|
||||
#### Scenario: message_start 事件编码完整 message 字段
|
||||
|
||||
- **WHEN** 编码 MessageStartEvent
|
||||
- **THEN** SHALL 输出 `message` 对象包含以下字段:
|
||||
- `id`: 来自 event.Message.ID
|
||||
- `type`: 固定值 `"message"`
|
||||
- `role`: 固定值 `"assistant"`
|
||||
- `content`: 固定值 `[]`(空数组)
|
||||
- `model`: 来自 event.Message.Model
|
||||
- `stop_reason`: 固定值 `null`
|
||||
- `stop_sequence`: 固定值 `null`
|
||||
- `usage`: 来自 event.Message.Usage;若 Usage 为 nil,SHALL 输出 `{"input_tokens": 0, "output_tokens": 0}`
|
||||
|
||||
#### Scenario: message_delta 事件编码包含 usage 字段
|
||||
|
||||
- **WHEN** 编码 MessageDeltaEvent
|
||||
- **THEN** SHALL 输出 `usage` 字段
|
||||
- **WHEN** event.Usage 为 nil
|
||||
- **THEN** SHALL 输出 `{"output_tokens": 0}`
|
||||
|
||||
### Requirement: Anthropic 错误编码
|
||||
|
||||
系统 SHALL 实现 Anthropic 协议的错误编码。
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
- `protocolName()` SHALL 返回 `"openai"`
|
||||
- `supportsPassthrough()` SHALL 返回 true
|
||||
- `buildHeaders(provider)` SHALL 构建 `Authorization: Bearer <api_key>` 和 `Content-Type: application/json`
|
||||
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射 URL 路径,不带 `/v1` 前缀
|
||||
- `detectInterfaceType(nativePath)` SHALL 识别带 `/v1` 前缀的 OpenAI nativePath
|
||||
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射上游 URL 路径,输出路径不带 `/v1` 前缀
|
||||
- `supportsInterface()` SHALL 对 CHAT、MODELS、MODEL_INFO、EMBEDDINGS、RERANK 返回 true
|
||||
|
||||
#### Scenario: 认证 Header 构建
|
||||
@@ -24,12 +25,34 @@
|
||||
|
||||
#### Scenario: URL 映射
|
||||
|
||||
- **WHEN** interfaceType == CHAT
|
||||
- **WHEN** interfaceType == CHAT 且 nativePath 为 `/v1/chat/completions`
|
||||
- **THEN** SHALL 映射为 `/chat/completions`
|
||||
- **WHEN** interfaceType == MODELS
|
||||
- **WHEN** interfaceType == MODELS 且 nativePath 为 `/v1/models`
|
||||
- **THEN** SHALL 映射为 `/models`
|
||||
- **WHEN** interfaceType == EMBEDDINGS
|
||||
- **WHEN** interfaceType == MODEL_INFO 且 nativePath 为 `/v1/models/{id}`
|
||||
- **THEN** SHALL 映射为 `/models/{id}`
|
||||
- **WHEN** interfaceType == EMBEDDINGS 且 nativePath 为 `/v1/embeddings`
|
||||
- **THEN** SHALL 映射为 `/embeddings`
|
||||
- **WHEN** interfaceType == RERANK 且 nativePath 为 `/v1/rerank`
|
||||
- **THEN** SHALL 映射为 `/rerank`
|
||||
|
||||
#### Scenario: 接口类型识别
|
||||
|
||||
- **WHEN** 调用 `DetectInterfaceType("/v1/chat/completions")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypeChat`
|
||||
- **WHEN** 调用 `DetectInterfaceType("/v1/models")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypeModels`
|
||||
- **WHEN** 调用 `DetectInterfaceType("/v1/embeddings")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypeEmbeddings`
|
||||
- **WHEN** 调用 `DetectInterfaceType("/v1/rerank")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypeRerank`
|
||||
|
||||
#### Scenario: 旧无版本路径不再作为已适配接口
|
||||
|
||||
- **WHEN** 调用 `DetectInterfaceType("/chat/completions")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypePassthrough`
|
||||
- **WHEN** 调用 `DetectInterfaceType("/models")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypePassthrough`
|
||||
|
||||
### Requirement: OpenAI 请求解码(OpenAI → Canonical)
|
||||
|
||||
@@ -272,40 +295,40 @@ Encoder SHALL 维护状态:
|
||||
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
|
||||
### Requirement: 模型详情路径识别
|
||||
|
||||
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径。
|
||||
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的带 `/v1` 模型详情路径。
|
||||
|
||||
#### Scenario: 含斜杠的统一模型 ID 路径
|
||||
|
||||
- **WHEN** 路径为 `/models/openai/gpt-4`
|
||||
- **WHEN** 路径为 `/v1/models/openai/gpt-4`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||
|
||||
#### Scenario: 含多段斜杠的统一模型 ID 路径
|
||||
|
||||
- **WHEN** 路径为 `/models/azure/accounts/org-123/models/gpt-4`
|
||||
- **WHEN** 路径为 `/v1/models/azure/accounts/org-123/models/gpt-4`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||
|
||||
#### Scenario: 模型列表路径不受影响
|
||||
|
||||
- **WHEN** 路径为 `/models`
|
||||
- **WHEN** 路径为 `/v1/models`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
|
||||
|
||||
### Requirement: 提取统一模型 ID
|
||||
|
||||
OpenAI 适配器 SHALL 从路径中提取统一模型 ID。
|
||||
OpenAI 适配器 SHALL 从带 `/v1` 的模型详情路径中提取统一模型 ID。
|
||||
|
||||
#### Scenario: 标准路径提取
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models/openai/gpt-4")`
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/openai/gpt-4")`
|
||||
- **THEN** SHALL 返回 `"openai/gpt-4"`
|
||||
|
||||
#### Scenario: 复杂路径提取
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")`
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")`
|
||||
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
|
||||
|
||||
#### Scenario: 非模型详情路径
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models")`
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models")`
|
||||
- **THEN** SHALL 返回错误
|
||||
|
||||
### Requirement: 从请求体提取 model
|
||||
|
||||
78
openspec/specs/release-pipeline/spec.md
Normal file
78
openspec/specs/release-pipeline/spec.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 发布流水线
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 tag 驱动的发布流程、跨平台构建产物要求与 Draft Release 组装规则,确保发布结果可复现且可审阅。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Tag 驱动发布流水线
|
||||
|
||||
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。
|
||||
|
||||
#### Scenario: 有效发布 tag
|
||||
|
||||
- **WHEN** 仓库收到 `v1.2.3` tag push
|
||||
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
|
||||
|
||||
#### Scenario: 普通分支推送
|
||||
|
||||
- **WHEN** 仓库收到非 tag 的分支 push
|
||||
- **THEN** 系统 SHALL NOT 创建 GitHub Release
|
||||
|
||||
### Requirement: 三平台发布构建
|
||||
|
||||
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物。
|
||||
|
||||
#### Scenario: Linux 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 Linux 构建 job
|
||||
- **THEN** 系统 SHALL 生成 Linux server 发布资产
|
||||
- **AND** 系统 SHALL 生成 Linux desktop 发布资产
|
||||
|
||||
#### Scenario: Windows 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 Windows 构建 job
|
||||
- **THEN** 系统 SHALL 生成 Windows server 发布资产
|
||||
- **AND** 系统 SHALL 生成 Windows desktop 发布资产
|
||||
|
||||
#### Scenario: macOS 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 macOS 构建 job
|
||||
- **THEN** 系统 SHALL 生成 darwin-amd64 server 发布资产
|
||||
- **AND** 系统 SHALL 生成 darwin-arm64 server 发布资产
|
||||
- **AND** 系统 SHALL 生成 macOS desktop universal 发布资产
|
||||
|
||||
### Requirement: 版本化发布资产命名
|
||||
|
||||
系统 SHALL 为 server 与 desktop 的发布资产使用包含统一版本号和目标平台信息的文件名,确保 Release 页面可直接区分产物用途与平台。
|
||||
|
||||
#### Scenario: server 资产命名
|
||||
|
||||
- **WHEN** 当前发布版本为 `1.2.3`
|
||||
- **THEN** Linux server 发布资产文件名 SHALL 包含 `1.2.3`、`linux` 和 `amd64`
|
||||
- **AND** Windows server 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64`
|
||||
- **AND** macOS server 发布资产文件名 SHALL 分别包含 `1.2.3`、`darwin`、`amd64` 与 `1.2.3`、`darwin`、`arm64`
|
||||
|
||||
#### Scenario: desktop 资产命名
|
||||
|
||||
- **WHEN** 当前发布版本为 `1.2.3`
|
||||
- **THEN** Linux desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `linux`
|
||||
- **AND** Windows desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `windows`
|
||||
- **AND** macOS desktop universal 发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS`
|
||||
|
||||
### Requirement: Draft Release 组装
|
||||
|
||||
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release,由人工确认后再公开发布。
|
||||
|
||||
#### Scenario: 发布成功时创建 Draft Release
|
||||
|
||||
- **WHEN** 版本校验通过且三平台发布资产构建完成
|
||||
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
|
||||
- **AND** 系统 SHALL 上传 server 与 desktop 的全部发布资产
|
||||
|
||||
#### Scenario: 构建失败时阻止完成发布
|
||||
|
||||
- **WHEN** 任一平台发布资产构建失败或版本校验失败
|
||||
- **THEN** 发布流水线 SHALL 失败
|
||||
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
|
||||
68
openspec/specs/repository-versioning/spec.md
Normal file
68
openspec/specs/repository-versioning/spec.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 仓库版本管理
|
||||
|
||||
## Purpose
|
||||
|
||||
定义仓库统一版本源、镜像同步、发布一致性校验与构建版本注入规则,确保所有构建消费者使用同一版本语义。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 统一版本源
|
||||
|
||||
系统 SHALL 使用仓库根目录 `VERSION` 文件作为全仓唯一版本源,文件内容 SHALL 仅包含一行 `x.y.z` 格式的语义版本号。
|
||||
|
||||
#### Scenario: 读取有效版本号
|
||||
|
||||
- **WHEN** 仓库中的 `VERSION` 文件内容为 `1.2.3`
|
||||
- **THEN** 本地工具和 CI SHALL 将 `1.2.3` 视为当前仓库版本
|
||||
- **AND** 其他版本消费者 SHALL NOT 覆盖该值作为权威来源
|
||||
|
||||
#### Scenario: 拒绝非法版本格式
|
||||
|
||||
- **WHEN** `VERSION` 文件内容不是 `x.y.z` 格式
|
||||
- **THEN** 版本同步与发布校验流程 SHALL 失败
|
||||
- **AND** 系统 SHALL 输出格式错误信息
|
||||
|
||||
### Requirement: 版本镜像同步
|
||||
|
||||
系统 SHALL 提供仓库内的版本同步入口,将 `VERSION` 中的版本值写入需要镜像版本号的构建消费者。
|
||||
|
||||
#### Scenario: 同步前端镜像字段
|
||||
|
||||
- **WHEN** 执行版本同步流程且 `VERSION` 为 `1.2.3`
|
||||
- **THEN** `frontend/package.json` 中的 `version` 字段 SHALL 被同步为 `1.2.3`
|
||||
|
||||
#### Scenario: 同步构建模板值
|
||||
|
||||
- **WHEN** 执行版本同步流程且存在依赖版本号的构建模板或元数据模板
|
||||
- **THEN** 这些模板消费的版本值 SHALL 与 `VERSION` 保持一致
|
||||
- **AND** 系统 SHALL NOT 要求用户手工修改多个版本文件
|
||||
|
||||
### Requirement: 发布版本一致性校验
|
||||
|
||||
系统 SHALL 在发布前校验仓库版本与 Git tag 一致,确保发布锚点与仓库状态不漂移。
|
||||
|
||||
#### Scenario: tag 与 VERSION 一致
|
||||
|
||||
- **WHEN** 发布使用的 Git tag 为 `v1.2.3` 且 `VERSION` 为 `1.2.3`
|
||||
- **THEN** 发布校验 SHALL 通过
|
||||
|
||||
#### Scenario: tag 与 VERSION 不一致
|
||||
|
||||
- **WHEN** 发布使用的 Git tag 为 `v1.2.4` 但 `VERSION` 为 `1.2.3`
|
||||
- **THEN** 发布校验 SHALL 失败
|
||||
- **AND** 系统 SHALL 阻止后续发布步骤继续执行
|
||||
|
||||
### Requirement: 统一构建版本注入
|
||||
|
||||
系统 SHALL 在构建阶段把统一版本信息注入 frontend、server 和 desktop,而不是在运行时依赖外部发布平台查询版本。
|
||||
|
||||
#### Scenario: Go 二进制注入版本元数据
|
||||
|
||||
- **WHEN** 构建 server 或 desktop 二进制
|
||||
- **THEN** 构建流程 SHALL 注入 `version`、`commit` 和 `buildTime` 元数据
|
||||
|
||||
#### Scenario: 前端注入构建版本
|
||||
|
||||
- **WHEN** 执行前端生产构建
|
||||
- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION`
|
||||
- **AND** 该值 SHALL 等于 `VERSION` 中的版本号
|
||||
@@ -157,26 +157,33 @@
|
||||
|
||||
### Requirement: 集成到构建流程
|
||||
|
||||
测试 SHALL 集成到构建流程中。
|
||||
测试 SHALL 同时集成到根目录公共流程和 backend 局部流程中。
|
||||
|
||||
#### Scenario: 运行测试命令
|
||||
|
||||
- **WHEN** 执行 `make test` 命令
|
||||
- **THEN** SHALL 运行所有单元测试和集成测试
|
||||
- **THEN** SHALL 运行 backend 核心测试、frontend 的 Vitest 单元/组件测试和 desktop 专属测试
|
||||
- **THEN** SHALL NOT 运行 MySQL 专项测试和 frontend E2E 测试
|
||||
- **THEN** SHALL 显示测试结果
|
||||
- **THEN** SHALL 在测试失败时返回非零退出码
|
||||
|
||||
#### Scenario: 运行 backend 局部测试命令
|
||||
|
||||
- **WHEN** 在 `backend/` 目录执行 `make test` 命令
|
||||
- **THEN** SHALL 运行 backend 核心测试
|
||||
- **THEN** SHALL NOT 运行 frontend 的 Vitest 单元/组件测试、desktop 专属测试、MySQL 专项测试或 frontend E2E 测试
|
||||
|
||||
#### Scenario: 分类测试命令
|
||||
|
||||
- **WHEN** 执行 `make test-unit` 命令
|
||||
- **WHEN** 在 `backend/` 目录执行 `make test-unit` 命令
|
||||
- **THEN** SHALL 仅运行 `./internal/...` 和 `./pkg/...` 下的单元测试
|
||||
|
||||
- **WHEN** 执行 `make test-integration` 命令
|
||||
- **WHEN** 在 `backend/` 目录执行 `make test-integration` 命令
|
||||
- **THEN** SHALL 仅运行 `./tests/...` 下的集成测试
|
||||
|
||||
#### Scenario: 覆盖率检查命令
|
||||
|
||||
- **WHEN** 执行 `make test-coverage` 命令
|
||||
- **WHEN** 在 `backend/` 目录执行 `make test-coverage` 命令
|
||||
- **THEN** SHALL 运行测试并生成覆盖率报告
|
||||
- **THEN** SHALL 检查覆盖率是否达标
|
||||
- **THEN** SHALL 在覆盖率不足时返回非零退出码
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代理路由入口一致
|
||||
|
||||
server 和 desktop 运行模式 SHALL 使用一致的代理路由入口,确保外部应用在不同运行模式下使用相同的协议 URL。
|
||||
|
||||
#### Scenario: server 代理路由
|
||||
|
||||
- **WHEN** server 模式启动 HTTP 路由
|
||||
- **THEN** SHALL 注册 `/{protocol}/*path` 形式的代理路由
|
||||
- **THEN** `/openai/v1/chat/completions` SHALL 进入 ProxyHandler 并提取 clientProtocol 为 `openai`
|
||||
|
||||
#### Scenario: desktop 代理路由
|
||||
|
||||
- **WHEN** desktop 模式启动 HTTP 路由
|
||||
- **THEN** SHALL 注册显式 `/openai/*path` 和 `/anthropic/*path` 代理路由
|
||||
- **THEN** SHALL 在进入 ProxyHandler 前设置 clientProtocol 参数
|
||||
- **THEN** `/openai/v1/chat/completions` SHALL 进入 ProxyHandler 并提取 clientProtocol 为 `openai`
|
||||
|
||||
### Requirement: 实现统一代理 Handler
|
||||
|
||||
系统 SHALL 实现统一的 ProxyHandler,替代现有的 OpenAIHandler 和 AnthropicHandler。
|
||||
@@ -16,13 +33,28 @@ ProxyHandler SHALL 依赖 ConversionEngine、ProviderClient、RoutingService、S
|
||||
|
||||
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
|
||||
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
|
||||
- **THEN** SHALL 剥离前缀得到 nativePath
|
||||
- **THEN** nativePath SHALL 不添加任何前缀,直接使用 path 参数
|
||||
- **THEN** SHALL 只剥离第一段协议前缀得到 nativePath
|
||||
- **THEN** nativePath SHALL 不添加任何前缀,也不移除协议内部版本段,直接使用 path 参数
|
||||
|
||||
#### Scenario: OpenAI v1 nativePath 保留
|
||||
|
||||
- **WHEN** 收到 `/openai/v1/chat/completions` 请求
|
||||
- **THEN** SHALL 从 URL 第一段提取 `openai` 作为 clientProtocol
|
||||
- **THEN** SHALL 剥离 `/openai` 后得到 nativePath `/v1/chat/completions`
|
||||
- **THEN** SHALL 将 `/v1/chat/completions` 交给 OpenAI adapter 识别
|
||||
|
||||
#### Scenario: Anthropic v1 nativePath 保留
|
||||
|
||||
- **WHEN** 收到 `/anthropic/v1/messages` 请求
|
||||
- **THEN** SHALL 从 URL 第一段提取 `anthropic` 作为 clientProtocol
|
||||
- **THEN** SHALL 剥离 `/anthropic` 后得到 nativePath `/v1/messages`
|
||||
- **THEN** SHALL 将 `/v1/messages` 交给 Anthropic adapter 识别
|
||||
|
||||
#### Scenario: 协议前缀必须是已注册协议
|
||||
|
||||
- **WHEN** 收到的 URL 前缀不是已注册的协议名称
|
||||
- **THEN** SHALL 返回 404 错误
|
||||
- **THEN** 错误响应 SHALL 使用应用统一错误格式
|
||||
|
||||
#### Scenario: 接口类型识别
|
||||
|
||||
@@ -37,131 +69,157 @@ ProxyHandler SHALL 按以下流程处理非流式请求。
|
||||
#### Scenario: 完整转换流程
|
||||
|
||||
- **WHEN** 收到非流式请求
|
||||
- **THEN** SHALL 解析请求体为 JSON
|
||||
- **THEN** SHALL 调用 RoutingService.Route(modelName) 获取路由结果
|
||||
- **THEN** SHALL 通过客户端 adapter 明确支持的接口规则提取 model
|
||||
- **THEN** SHALL 调用 RoutingService.RouteByModelName(providerID, modelName) 获取路由结果
|
||||
- **THEN** SHALL 从路由结果的 Provider.Protocol 获取 providerProtocol
|
||||
- **THEN** SHALL 构建 TargetProvider(base_url, api_key, model_name, adapter_config)
|
||||
- **THEN** SHALL 调用 engine.convertHttpRequest(body, clientProtocol, providerProtocol, provider)
|
||||
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
||||
- **THEN** SHALL 调用 providerClient.Send(ctx, requestSpec)
|
||||
- **THEN** SHALL 调用 engine.convertHttpResponse(response, clientProtocol, providerProtocol, interfaceType)
|
||||
- **THEN** 若上游响应状态码为 2xx,SHALL 调用 engine.convertHttpResponse 转换响应
|
||||
- **THEN** SHALL 将转换后的响应返回给客户端
|
||||
|
||||
#### Scenario: 路由失败处理
|
||||
|
||||
- **WHEN** RoutingService.Route 返回错误
|
||||
- **THEN** SHALL 使用 clientProtocol 对应的 Adapter.encodeError 编码错误响应
|
||||
- **WHEN** RoutingService.RouteByModelName 返回错误
|
||||
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
|
||||
- **THEN** SHALL 返回适当的 HTTP 状态码
|
||||
|
||||
#### Scenario: 上游请求失败处理
|
||||
|
||||
- **WHEN** ProviderClient.Send 返回错误
|
||||
- **THEN** SHALL 使用 clientProtocol 对应的 Adapter.encodeError 编码错误响应
|
||||
- **THEN** SHALL 包装原始错误信息
|
||||
- **WHEN** ProviderClient.Send 未收到上游 HTTP 响应并返回错误
|
||||
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
|
||||
- **THEN** SHALL 包装原始错误信息用于日志
|
||||
|
||||
#### Scenario: 上游非 2xx 响应透传
|
||||
|
||||
- **WHEN** ProviderClient.Send 收到上游 HTTP 响应且状态码不是 2xx
|
||||
- **THEN** ProxyHandler SHALL NOT 调用 engine.convertHttpResponse
|
||||
- **THEN** SHALL 透传上游 status code
|
||||
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
||||
- **THEN** SHALL 透传上游 body
|
||||
|
||||
### Requirement: 流式请求处理流程
|
||||
|
||||
ProxyHandler SHALL 按以下流程处理流式请求。
|
||||
|
||||
#### Scenario: 流式转换流程
|
||||
#### Scenario: 跨协议流式转换流程
|
||||
|
||||
- **WHEN** 请求中 stream=true 或接口类型为 CHAT 且请求体含 stream:true
|
||||
- **WHEN** 请求中 stream=true 且 clientProtocol != providerProtocol
|
||||
- **THEN** SHALL 执行与非流式相同的前置处理(路由、构建 TargetProvider)
|
||||
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
||||
- **THEN** SHALL 调用 providerClient.SendStream(ctx, requestSpec) 获取流
|
||||
- **THEN** SHALL 调用 engine.createStreamConverter(clientProtocol, providerProtocol, provider)
|
||||
- **THEN** 若上游返回 2xx 流式响应,SHALL 调用 engine.createStreamConverter(clientProtocol, providerProtocol, provider)
|
||||
- **THEN** SHALL 设置响应 Header `Content-Type: text/event-stream`
|
||||
- **THEN** SHALL 对每个流 chunk 调用 converter.processChunk 并写入响应
|
||||
- **THEN** SHALL 在流结束时调用 converter.flush() 刷新缓冲
|
||||
|
||||
#### Scenario: 同协议透传流式
|
||||
#### Scenario: 同协议 raw 流式透传
|
||||
|
||||
- **WHEN** clientProtocol == providerProtocol
|
||||
- **WHEN** clientProtocol == providerProtocol 且响应不需要 model 改写
|
||||
- **THEN** SHALL 直接将上游 SSE 字节流写入响应
|
||||
- **THEN** SHALL 保留 SSE frame 边界和结束标记
|
||||
- **THEN** SHALL NOT 做任何解析或转换
|
||||
|
||||
#### Scenario: 同协议流式 Smart Passthrough
|
||||
|
||||
- **WHEN** clientProtocol == providerProtocol 且响应需要 model 改写
|
||||
- **THEN** SHALL 按 SSE frame 读取上游流
|
||||
- **THEN** SHALL 仅改写 `data` JSON payload 内的 model 字段
|
||||
- **THEN** SHALL 原样保留 `[DONE]` 结束标记
|
||||
- **THEN** SHALL 重建合法 SSE frame 写回客户端
|
||||
- **THEN** SHALL NOT 做 Canonical decode/encode 转换
|
||||
|
||||
#### Scenario: 流式上游非 2xx 响应透传
|
||||
|
||||
- **WHEN** 流式请求收到上游非 2xx HTTP 响应
|
||||
- **THEN** SHALL NOT 创建 StreamConverter
|
||||
- **THEN** SHALL 透传上游 status code、过滤后的 headers 和 body
|
||||
|
||||
#### Scenario: 流式错误处理
|
||||
|
||||
- **WHEN** 流过程中发生错误
|
||||
- **WHEN** 流过程中发生网关层读取或写入错误
|
||||
- **THEN** SHALL 记录错误日志
|
||||
- **THEN** SHALL 关闭响应流
|
||||
|
||||
### Requirement: 统计记录
|
||||
|
||||
ProxyHandler SHALL 记录请求统计。
|
||||
|
||||
#### Scenario: 异步记录统计
|
||||
|
||||
- **WHEN** 请求处理完成(成功或失败)
|
||||
- **THEN** SHALL 异步调用 StatsService.Record
|
||||
- **THEN** SHALL NOT 阻塞响应返回
|
||||
|
||||
### Requirement: GET 请求透传
|
||||
|
||||
ProxyHandler SHALL 支持 GET 请求的扩展层接口代理。
|
||||
ProxyHandler SHALL 支持无请求体 GET 请求和未知接口透传。
|
||||
|
||||
#### Scenario: Models 接口<EFBFBD><EFBFBD>理
|
||||
#### Scenario: Models 接口本地处理
|
||||
|
||||
- **WHEN** 收到客户端协议的模型列表路径请求,例如 `GET /openai/v1/models` 或 `GET /anthropic/v1/models`
|
||||
- **THEN** SHALL 走模型列表本地聚合流程
|
||||
- **THEN** SHALL NOT 请求上游供应商
|
||||
|
||||
#### Scenario: 未知 GET 接口透传
|
||||
|
||||
- **WHEN** 收到未被 adapter 识别为本地处理接口的 GET 请求
|
||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
||||
- **THEN** SHALL 保留原始 query string
|
||||
|
||||
- **WHEN** 收到 GET /{protocol}/v1/models 请求
|
||||
- **THEN** SHALL 执行路由和协议识别
|
||||
- **THEN** SHALL 调用 engine.convertHttpRequest(GET 请求 body 为空)
|
||||
- **THEN** SHALL 调用 providerClient.Send 发送请求
|
||||
- **THEN** SHALL 调用 engine.convertHttpResponse 转换响应格式
|
||||
- **THEN** SHALL 返回转换后的响应
|
||||
### Requirement: 代理请求路由
|
||||
|
||||
ProxyHandler SHALL 使用统一模型 ID 路由所有代理请求。
|
||||
ProxyHandler SHALL 使用统一模型 ID 路由 adapter 明确支持提取 model 的代理请求。
|
||||
|
||||
#### Scenario: 提取统一模型 ID
|
||||
|
||||
- **WHEN** 收到 Chat、Embeddings 或 Rerank 接口的 POST 请求(含请求体)
|
||||
- **WHEN** 收到 adapter 明确支持提取 model 的接口请求(如 Chat、Embeddings 或 Rerank)且请求体非空
|
||||
- **THEN** SHALL 调用客户端协议 adapter 的 `ExtractModelName(body, ifaceType)` 提取 model 值
|
||||
- **THEN** SHALL 调用 `ParseUnifiedModelID` 解析得到 providerID 和 modelName
|
||||
- **THEN** SHALL 调用 `RoutingService.RouteByModelName(providerID, modelName)` 路由
|
||||
|
||||
#### Scenario: 未知接口不猜测 model
|
||||
|
||||
- **WHEN** 收到 adapter 未明确支持提取 model 的接口请求
|
||||
- **THEN** ProxyHandler SHALL NOT 尝试通用解析顶层 model 字段
|
||||
- **THEN** SHALL 按无 model 请求走 forwardPassthrough
|
||||
|
||||
#### Scenario: GET 请求或无请求体
|
||||
|
||||
- **WHEN** 收到 GET 请求或请求体为空或请求体中无法提取 model 字段
|
||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容未适配的客户端和无 body 请求)
|
||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
||||
|
||||
#### Scenario: 无效的统一模型 ID
|
||||
|
||||
- **WHEN** 请求体中 `model` 字段不是有效的统一模型 ID 格式(不含 `/`)
|
||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容使用原始模型名的客户端)
|
||||
- **WHEN** adapter 已提取 model 字段但该字段不是有效的统一模型 ID 格式(不含 `/`)
|
||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
||||
|
||||
#### Scenario: 模型不存在
|
||||
|
||||
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的 provider_id + model_name 组合
|
||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||
|
||||
#### Scenario: 模型已禁用
|
||||
|
||||
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
|
||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||
|
||||
#### Scenario: 供应商已禁用
|
||||
|
||||
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
|
||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||
|
||||
### Requirement: 同协议 Smart Passthrough
|
||||
|
||||
当客户端协议与供应商协议相同时,ProxyHandler SHALL 使用 Smart Passthrough 处理 Chat、Embedding、Rerank 请求。
|
||||
当客户端协议与供应商协议相同时,ProxyHandler SHALL 使用 Smart Passthrough 处理 adapter 明确支持的 Chat、Embedding、Rerank 请求。
|
||||
|
||||
#### Scenario: 同协议非流式请求
|
||||
|
||||
- **WHEN** 客户端协议 == 供应商协议,且为非流式请求
|
||||
- **THEN** SHALL 调用 adapter 的 `RewriteRequestModelName(body, modelName, ifaceType)` 将请求体中 model 从统一 ID 改写为上游模型名
|
||||
- **THEN** SHALL 构建 URL 和 Headers(同当前透传逻辑)
|
||||
- **THEN** SHALL 使用 providerAdapter.BuildUrl(nativePath, ifaceType) 构建上游 URL 路径
|
||||
- **THEN** SHALL 使用 providerAdapter.BuildHeaders(provider) 构建 Headers
|
||||
- **THEN** SHALL 发送改写后的请求体到上游
|
||||
- **THEN** SHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
|
||||
- **THEN** SHALL NOT 对 body 做全量 decode → encode,保持未改写字段的原始 bytes
|
||||
- **THEN** 若上游返回 2xx,SHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
|
||||
- **THEN** SHALL NOT 对 body 做 Canonical 全量 decode → encode,保持未改写字段的 JSON 内容和类型不变,但不承诺保留原始字节顺序或空白
|
||||
|
||||
#### Scenario: 同协议流式请求
|
||||
|
||||
- **WHEN** 客户端协议 == 供应商协议,且为流式请求
|
||||
- **THEN** SHALL 对请求体做 `RewriteRequestModelName` 改写 model 字段
|
||||
- **THEN** SHALL 逐 SSE chunk 调用 `RewriteResponseModelName` 改写响应中 model 字段
|
||||
- **THEN** SHALL NOT 对 chunk 做全量 decode → encode
|
||||
- **THEN** SHALL 使用 providerAdapter.BuildUrl(nativePath, ifaceType) 构建上游 URL 路径
|
||||
- **THEN** SHALL 按 raw passthrough 或 SSE frame 级 Smart Passthrough 处理响应流
|
||||
- **THEN** SHALL NOT 对 chunk 做 Canonical 全量 decode → encode
|
||||
|
||||
#### Scenario: Smart Passthrough 保真性
|
||||
|
||||
@@ -196,6 +254,21 @@ ProxyHandler SHALL 从数据库聚合返回模型列表,不再透传上游。
|
||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||
- **THEN** SHALL NOT 请求上游供应商
|
||||
|
||||
#### Scenario: OpenAI Models 本地聚合
|
||||
|
||||
- **WHEN** 收到 `GET /openai/v1/models` 请求
|
||||
- **THEN** nativePath SHALL 为 `/v1/models`
|
||||
- **THEN** OpenAI adapter SHALL 将接口类型识别为 `InterfaceTypeModels`
|
||||
- **THEN** ProxyHandler SHALL 从数据库聚合返回 OpenAI Models 格式响应
|
||||
|
||||
#### Scenario: 客户端协议模型列表路径
|
||||
|
||||
- **WHEN** 收到客户端协议的模型列表路径请求,例如 `GET /openai/v1/models` 或 `GET /anthropic/v1/models`
|
||||
- **THEN** SHALL 从数据库查询所有 enabled 的模型(关联 enabled 的供应商)
|
||||
- **THEN** SHALL 组装 `CanonicalModelList`,每个模型的 ID 字段为统一模型 ID(`provider_id/model_name`),Name 字段为 model_name,OwnedBy 字段为 provider_id
|
||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||
- **THEN** SHALL NOT 请求上游供应商
|
||||
|
||||
#### Scenario: 无可用模型
|
||||
|
||||
- **WHEN** 数据库中没有 enabled 的模型
|
||||
@@ -215,10 +288,27 @@ ProxyHandler SHALL 从数据库查询返回模型详情,不再透传上游。
|
||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||
- **THEN** SHALL NOT 请求上游供应商
|
||||
|
||||
#### Scenario: OpenAI ModelInfo 本地查询
|
||||
|
||||
- **WHEN** 收到 `GET /openai/v1/models/openai/gpt-4` 请求
|
||||
- **THEN** nativePath SHALL 为 `/v1/models/openai/gpt-4`
|
||||
- **THEN** OpenAI adapter SHALL 提取统一模型 ID `openai/gpt-4`
|
||||
- **THEN** ProxyHandler SHALL 从数据库查询模型详情并返回 OpenAI ModelInfo 格式响应
|
||||
|
||||
#### Scenario: 客户端协议模型详情路径
|
||||
|
||||
- **WHEN** 收到客户端协议的模型详情路径请求,例如 `GET /openai/v1/models/{provider_id}/{model_name}` 或 `GET /anthropic/v1/models/{provider_id}/{model_name}`
|
||||
- **THEN** SHALL 调用 adapter 的 `ExtractUnifiedModelID` 提取统一模型 ID
|
||||
- **THEN** SHALL 解析统一模型 ID 得到 providerID 和 modelName
|
||||
- **THEN** SHALL 从数据库查询对应的模型和供应商
|
||||
- **THEN** SHALL 组装 `CanonicalModelInfo`,ID 字段为统一模型 ID(`provider_id/model_name`),Name 字段为 model_name,OwnedBy 字段为 provider_id
|
||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||
- **THEN** SHALL NOT 请求上游供应商
|
||||
|
||||
#### Scenario: 模型详情不存在
|
||||
|
||||
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
|
||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||
|
||||
### Requirement: 统计记录
|
||||
|
||||
@@ -228,3 +318,4 @@ ProxyHandler SHALL 使用 providerID 和 modelName 记录使用统计。
|
||||
|
||||
- **WHEN** 代理请求成功完成
|
||||
- **THEN** SHALL 异步调用 `StatsService.Record(providerID, modelName)`
|
||||
- **THEN** SHALL NOT 阻塞响应返回
|
||||
|
||||
114
openspec/specs/workspace-command-flows/spec.md
Normal file
114
openspec/specs/workspace-command-flows/spec.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Workspace Command Flows
|
||||
|
||||
## Purpose
|
||||
|
||||
定义根目录 `Makefile` 与 `backend/Makefile` 的公开命令边界,明确全仓命令、产品级命令和 backend 局部维护命令的职责分层。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 根目录公开命令分层
|
||||
|
||||
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。
|
||||
|
||||
#### Scenario: 查看根目录公开命令
|
||||
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
||||
- **THEN** SHALL 仅看到 `lint`、`test`、`clean`、`version-sync`、`version-check`、`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-linux`、`release-assets-windows`、`release-assets-macos` 这类公共入口
|
||||
|
||||
#### Scenario: 根目录不暴露局部和内部命令
|
||||
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
||||
- **THEN** SHALL NOT 暴露 `backend-*`、`frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤
|
||||
- **THEN** SHALL NOT 暴露 `dev`、`build`、`all`、`desktop-dev`、`desktop-build` 这类模糊或聚合式公共命令
|
||||
|
||||
### Requirement: 全局质量与清理命令
|
||||
|
||||
根目录 `Makefile` SHALL 提供 `lint`、`test`、`clean` 作为全仓默认入口。
|
||||
|
||||
#### Scenario: 执行全局 lint
|
||||
- **WHEN** 执行 `make lint`
|
||||
- **THEN** SHALL 运行 backend 的 Go lint 检查和 frontend 的 lint / format check
|
||||
- **THEN** SHALL 在任一检查失败时返回非零退出码
|
||||
|
||||
#### Scenario: 执行全局 test
|
||||
- **WHEN** 执行 `make test`
|
||||
- **THEN** SHALL 运行 backend 核心测试、frontend 的 Vitest 单元/组件测试和 desktop 专属测试
|
||||
- **THEN** SHALL NOT 运行 MySQL 专项测试和 frontend E2E 测试
|
||||
- **THEN** SHALL 在任一测试失败时返回非零退出码
|
||||
|
||||
#### Scenario: 执行全局 clean
|
||||
- **WHEN** 执行 `make clean`
|
||||
- **THEN** SHALL 清理 server 与 desktop 相关的构建产物、发布产物和测试报告
|
||||
- **THEN** SHALL NOT 删除 `frontend/node_modules`、Go module cache 或 bun cache 之类依赖目录和全局缓存
|
||||
|
||||
### Requirement: Server 产品命令
|
||||
|
||||
根目录 `Makefile` SHALL 提供面向前后端分离 server 模式的产品级命令。
|
||||
|
||||
#### Scenario: 启动 server 模式联调环境
|
||||
- **WHEN** 执行 `make server-run`
|
||||
- **THEN** SHALL 并行启动 Go 后端服务和前端 Vite 开发服务器
|
||||
- **THEN** SHALL 使前端继续通过现有代理访问本地 backend 服务
|
||||
|
||||
#### Scenario: 构建 server 模式产物
|
||||
- **WHEN** 执行 `make server-build`
|
||||
- **THEN** SHALL 生成 `backend/bin/server`
|
||||
- **AND** SHALL 生成 `frontend/dist`
|
||||
- **AND** SHALL 在构建前校验版本一致性
|
||||
- **AND** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
||||
|
||||
#### Scenario: 执行 server lint
|
||||
- **WHEN** 执行 `make server-lint`
|
||||
- **THEN** SHALL 运行 backend 的 Go lint 检查和 frontend 的 lint / format check
|
||||
|
||||
#### Scenario: 执行 server test
|
||||
- **WHEN** 执行 `make server-test`
|
||||
- **THEN** SHALL 运行 backend 核心测试和 frontend 的 Vitest 单元/组件测试
|
||||
- **THEN** SHALL NOT 运行 MySQL 专项测试、frontend E2E 测试或 desktop 专属测试
|
||||
|
||||
#### Scenario: 执行 server clean
|
||||
- **WHEN** 执行 `make server-clean`
|
||||
- **THEN** SHALL 清理 `backend/bin/server`、`frontend/dist` 以及 server 模式相关测试报告与临时产物
|
||||
|
||||
### Requirement: Desktop 产品命令
|
||||
|
||||
根目录 `Makefile` SHALL 提供按平台拆分的 desktop 构建命令,以及 desktop 产品级 `lint`、`test`、`clean` 命令。
|
||||
|
||||
#### Scenario: 执行按平台拆分的 desktop 构建
|
||||
- **WHEN** 执行 `make desktop-build-mac`、`make desktop-build-win` 或 `make desktop-build-linux`
|
||||
- **THEN** SHALL 分别构建对应平台的 desktop 产物
|
||||
- **THEN** SHALL 在构建前执行版本一致性校验
|
||||
- **THEN** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
||||
- **THEN** SHALL NOT 要求通过额外的 `desktop-build` 聚合命令触发构建
|
||||
|
||||
#### Scenario: 根目录不提供 desktop 开发聚合入口
|
||||
- **WHEN** 开发者查看根目录 `Makefile`
|
||||
- **THEN** SHALL NOT 提供 `desktop-dev` 作为公共命令
|
||||
|
||||
#### Scenario: 执行 desktop lint
|
||||
- **WHEN** 执行 `make desktop-lint`
|
||||
- **THEN** SHALL 运行 backend 的 Go lint 检查和 frontend 的 lint / format check
|
||||
|
||||
#### Scenario: 执行 desktop test
|
||||
- **WHEN** 执行 `make desktop-test`
|
||||
- **THEN** SHALL 运行 desktop 专属测试
|
||||
|
||||
#### Scenario: 执行 desktop clean
|
||||
- **WHEN** 执行 `make desktop-clean`
|
||||
- **THEN** SHALL 清理 desktop 构建目录、嵌入资源目录和 desktop 相关测试/打包产物
|
||||
|
||||
### Requirement: Release 命令沿用根目录入口
|
||||
|
||||
根目录 `Makefile` SHALL 继续提供 `release-assets-*` 作为发布资产入口,并与新的版本校验规则保持一致。
|
||||
|
||||
#### Scenario: 执行 release 资产命令
|
||||
- **WHEN** 执行 `make release-assets-linux`、`make release-assets-windows` 或 `make release-assets-macos`
|
||||
- **THEN** SHALL 在构建发布资产前执行版本一致性校验
|
||||
- **THEN** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
||||
|
||||
### Requirement: Backend 局部命令下沉
|
||||
|
||||
数据库迁移、MySQL 专项测试以及其他 backend 局部维护命令 SHALL 由 `backend/Makefile` 提供,而不是由根目录 `Makefile` 公开。
|
||||
|
||||
#### Scenario: 执行 backend 局部维护命令
|
||||
- **WHEN** 开发者需要执行数据库迁移或 MySQL 专项测试
|
||||
- **THEN** SHALL 在 `backend/` 目录使用 backend 局部 make 命令完成操作
|
||||
- **THEN** 根目录 `Makefile` SHALL NOT 提供等价的公共命令别名
|
||||
@@ -347,4 +347,4 @@ if __name__ == "__main__":
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
Apache License 2.0
|
||||
|
||||
Reference in New Issue
Block a user