Merge branch 'slovx2/main'
This commit is contained in:
63
.github/workflows/release.yml
vendored
63
.github/workflows/release.yml
vendored
@@ -4,6 +4,22 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g., v1.0.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
simple_release:
|
||||||
|
description: 'Simple release: only x86_64 GHCR image, skip other artifacts'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
# 环境变量:合并 workflow_dispatch 输入和 repository variable
|
||||||
|
# tag push 触发时读取 vars.SIMPLE_RELEASE,workflow_dispatch 时使用输入参数
|
||||||
|
env:
|
||||||
|
SIMPLE_RELEASE: ${{ github.event.inputs.simple_release == 'true' || vars.SIMPLE_RELEASE == 'true' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -19,7 +35,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Update VERSION file
|
- name: Update VERSION file
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF#refs/tags/v}
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
VERSION=${{ github.event.inputs.tag }}
|
||||||
|
VERSION=${VERSION#v}
|
||||||
|
else
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
fi
|
||||||
echo "$VERSION" > backend/cmd/server/VERSION
|
echo "$VERSION" > backend/cmd/server/VERSION
|
||||||
echo "Updated VERSION file to: $VERSION"
|
echo "Updated VERSION file to: $VERSION"
|
||||||
|
|
||||||
@@ -66,6 +87,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Download VERSION artifact
|
- name: Download VERSION artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -93,7 +115,10 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
|
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -113,7 +138,11 @@ jobs:
|
|||||||
- name: Get tag message
|
- name: Get tag message
|
||||||
id: tag_message
|
id: tag_message
|
||||||
run: |
|
run: |
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
TAG_NAME=${{ github.event.inputs.tag }}
|
||||||
|
else
|
||||||
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
fi
|
||||||
echo "Processing tag: $TAG_NAME"
|
echo "Processing tag: $TAG_NAME"
|
||||||
|
|
||||||
# 获取完整的 tag message(跳过第一行标题)
|
# 获取完整的 tag message(跳过第一行标题)
|
||||||
@@ -137,18 +166,21 @@ jobs:
|
|||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: '~> v2'
|
version: '~> v2'
|
||||||
args: release --clean --skip=validate
|
args: release --clean --skip=validate ${{ env.SIMPLE_RELEASE == 'true' && '--config=.goreleaser.simple.yaml' || '' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG_MESSAGE: ${{ steps.tag_message.outputs.message }}
|
TAG_MESSAGE: ${{ steps.tag_message.outputs.message }}
|
||||||
GITHUB_REPO_OWNER: ${{ github.repository_owner }}
|
GITHUB_REPO_OWNER: ${{ github.repository_owner }}
|
||||||
GITHUB_REPO_OWNER_LOWER: ${{ steps.lowercase.outputs.owner }}
|
GITHUB_REPO_OWNER_LOWER: ${{ steps.lowercase.outputs.owner }}
|
||||||
GITHUB_REPO_NAME: ${{ github.event.repository.name }}
|
GITHUB_REPO_NAME: ${{ github.event.repository.name }}
|
||||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }}
|
||||||
|
|
||||||
# Update DockerHub description
|
# Update DockerHub description
|
||||||
- name: Update DockerHub description
|
- name: Update DockerHub description
|
||||||
|
if: ${{ env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }}
|
||||||
uses: peter-evans/dockerhub-description@v4
|
uses: peter-evans/dockerhub-description@v4
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -158,9 +190,11 @@ jobs:
|
|||||||
|
|
||||||
# Send Telegram notification
|
# Send Telegram notification
|
||||||
- name: Send Telegram Notification
|
- name: Send Telegram Notification
|
||||||
|
if: ${{ env.SIMPLE_RELEASE != 'true' }}
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# 检查必要的环境变量
|
# 检查必要的环境变量
|
||||||
@@ -169,10 +203,13 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
TAG_NAME=${{ github.event.inputs.tag }}
|
||||||
|
else
|
||||||
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
fi
|
||||||
VERSION=${TAG_NAME#v}
|
VERSION=${TAG_NAME#v}
|
||||||
REPO="${{ github.repository }}"
|
REPO="${{ github.repository }}"
|
||||||
DOCKER_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/sub2api"
|
|
||||||
GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase
|
GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase
|
||||||
|
|
||||||
# 获取 tag message 内容
|
# 获取 tag message 内容
|
||||||
@@ -194,14 +231,20 @@ jobs:
|
|||||||
|
|
||||||
MESSAGE+="🐳 *Docker 部署:*"$'\n'
|
MESSAGE+="🐳 *Docker 部署:*"$'\n'
|
||||||
MESSAGE+="\`\`\`bash"$'\n'
|
MESSAGE+="\`\`\`bash"$'\n'
|
||||||
MESSAGE+="# Docker Hub"$'\n'
|
# 根据是否配置 DockerHub 动态生成
|
||||||
MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG_NAME}"$'\n'
|
if [ -n "$DOCKERHUB_USERNAME" ]; then
|
||||||
MESSAGE+="# GitHub Container Registry"$'\n'
|
DOCKER_IMAGE="${DOCKERHUB_USERNAME}/sub2api"
|
||||||
|
MESSAGE+="# Docker Hub"$'\n'
|
||||||
|
MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG_NAME}"$'\n'
|
||||||
|
MESSAGE+="# GitHub Container Registry"$'\n'
|
||||||
|
fi
|
||||||
MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG_NAME}"$'\n'
|
MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG_NAME}"$'\n'
|
||||||
MESSAGE+="\`\`\`"$'\n'$'\n'
|
MESSAGE+="\`\`\`"$'\n'$'\n'
|
||||||
MESSAGE+="🔗 *相关链接:*"$'\n'
|
MESSAGE+="🔗 *相关链接:*"$'\n'
|
||||||
MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG_NAME})"$'\n'
|
MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG_NAME})"$'\n'
|
||||||
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE})"$'\n'
|
if [ -n "$DOCKERHUB_USERNAME" ]; then
|
||||||
|
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE})"$'\n'
|
||||||
|
fi
|
||||||
MESSAGE+="• [GitHub Packages](https://github.com/${REPO}/pkgs/container/sub2api)"$'\n'$'\n'
|
MESSAGE+="• [GitHub Packages](https://github.com/${REPO}/pkgs/container/sub2api)"$'\n'$'\n'
|
||||||
MESSAGE+="#Sub2API #Release #${TAG_NAME//./_}"
|
MESSAGE+="#Sub2API #Release #${TAG_NAME//./_}"
|
||||||
|
|
||||||
|
|||||||
86
.goreleaser.simple.yaml
Normal file
86
.goreleaser.simple.yaml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 简化版 GoReleaser 配置 - 仅发布 x86_64 GHCR 镜像
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: sub2api
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy -C backend
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: sub2api
|
||||||
|
dir: backend
|
||||||
|
main: ./cmd/server
|
||||||
|
binary: sub2api
|
||||||
|
flags:
|
||||||
|
- -tags=embed
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X main.Commit={{.Commit}}
|
||||||
|
- -X main.Date={{.Date}}
|
||||||
|
- -X main.BuildType=release
|
||||||
|
|
||||||
|
# 跳过 archives
|
||||||
|
archives: []
|
||||||
|
|
||||||
|
# 跳过 checksum
|
||||||
|
checksum:
|
||||||
|
disable: true
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
disable: true
|
||||||
|
|
||||||
|
# 仅 GHCR x86_64 镜像
|
||||||
|
dockers:
|
||||||
|
- id: ghcr-amd64
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:latest"
|
||||||
|
dockerfile: Dockerfile.goreleaser
|
||||||
|
use: buildx
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .Commit }}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}"
|
||||||
|
|
||||||
|
# 跳过 manifests(单架构不需要)
|
||||||
|
docker_manifests: []
|
||||||
|
|
||||||
|
release:
|
||||||
|
github:
|
||||||
|
owner: "{{ .Env.GITHUB_REPO_OWNER }}"
|
||||||
|
name: "{{ .Env.GITHUB_REPO_NAME }}"
|
||||||
|
draft: false
|
||||||
|
prerelease: auto
|
||||||
|
name_template: "Sub2API {{.Version}} (Simple)"
|
||||||
|
# 跳过上传二进制包
|
||||||
|
skip_upload: true
|
||||||
|
header: |
|
||||||
|
> AI API Gateway Platform - 将 AI 订阅配额分发和管理
|
||||||
|
> ⚡ Simple Release: 仅包含 x86_64 GHCR 镜像
|
||||||
|
|
||||||
|
{{ .Env.TAG_MESSAGE }}
|
||||||
|
|
||||||
|
footer: |
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Installation
|
||||||
|
|
||||||
|
**Docker (x86_64 only):**
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- [GitHub Repository](https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }})
|
||||||
@@ -54,9 +54,11 @@ changelog:
|
|||||||
|
|
||||||
# Docker images
|
# Docker images
|
||||||
dockers:
|
dockers:
|
||||||
|
# DockerHub images (skipped if DOCKERHUB_USERNAME is 'skip')
|
||||||
- id: amd64
|
- id: amd64
|
||||||
goos: linux
|
goos: linux
|
||||||
goarch: amd64
|
goarch: amd64
|
||||||
|
skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}'
|
||||||
image_templates:
|
image_templates:
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: Dockerfile.goreleaser
|
||||||
@@ -69,6 +71,7 @@ dockers:
|
|||||||
- id: arm64
|
- id: arm64
|
||||||
goos: linux
|
goos: linux
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}'
|
||||||
image_templates:
|
image_templates:
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: Dockerfile.goreleaser
|
||||||
@@ -107,22 +110,27 @@ dockers:
|
|||||||
|
|
||||||
# Docker manifests for multi-arch support
|
# Docker manifests for multi-arch support
|
||||||
docker_manifests:
|
docker_manifests:
|
||||||
|
# DockerHub manifests (skipped if DOCKERHUB_USERNAME is 'skip')
|
||||||
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}"
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}"
|
||||||
|
skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}'
|
||||||
image_templates:
|
image_templates:
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:latest"
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:latest"
|
||||||
|
skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}'
|
||||||
image_templates:
|
image_templates:
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Major }}.{{ .Minor }}"
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Major }}.{{ .Minor }}"
|
||||||
|
skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}'
|
||||||
image_templates:
|
image_templates:
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Major }}"
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Major }}"
|
||||||
|
skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}'
|
||||||
image_templates:
|
image_templates:
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
@@ -169,9 +177,11 @@ release:
|
|||||||
|
|
||||||
**Docker:**
|
**Docker:**
|
||||||
```bash
|
```bash
|
||||||
|
{{ if ne .Env.DOCKERHUB_USERNAME "skip" -}}
|
||||||
# Docker Hub
|
# Docker Hub
|
||||||
docker pull {{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}
|
docker pull {{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}
|
||||||
|
|
||||||
|
{{ end -}}
|
||||||
# GitHub Container Registry
|
# GitHub Container Registry
|
||||||
docker pull ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}
|
docker pull ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 错误响应已在Forward中处理,这里只记录日志
|
// 错误响应已在Forward中处理,这里只记录日志
|
||||||
log.Printf("Forward request failed: %v", err)
|
log.Printf("Account %d: Forward request failed: %v", account.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Error response already handled in Forward, just log
|
// Error response already handled in Forward, just log
|
||||||
log.Printf("Forward request failed: %v", err)
|
log.Printf("Account %d: Forward request failed: %v", account.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,27 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
antigravityStickySessionTTL = time.Hour
|
antigravityStickySessionTTL = time.Hour
|
||||||
antigravityMaxRetries = 5
|
antigravityMaxRetries = 3
|
||||||
antigravityRetryBaseDelay = 1 * time.Second
|
antigravityRetryBaseDelay = 1 * time.Second
|
||||||
antigravityRetryMaxDelay = 16 * time.Second
|
antigravityRetryMaxDelay = 16 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// getSessionID 从 gin.Context 获取 session_id(用于日志追踪)
|
||||||
|
func getSessionID(c *gin.Context) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.GetHeader("session_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// logPrefix 生成统一的日志前缀
|
||||||
|
func logPrefix(sessionID, accountName string) string {
|
||||||
|
if sessionID != "" {
|
||||||
|
return fmt.Sprintf("[antigravity-Forward] session=%s account=%s", sessionID, accountName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[antigravity-Forward] account=%s", accountName)
|
||||||
|
}
|
||||||
|
|
||||||
// Antigravity 直接支持的模型(精确匹配透传)
|
// Antigravity 直接支持的模型(精确匹配透传)
|
||||||
var antigravitySupportedModels = map[string]bool{
|
var antigravitySupportedModels = map[string]bool{
|
||||||
"claude-opus-4-5-thinking": true,
|
"claude-opus-4-5-thinking": true,
|
||||||
@@ -46,10 +62,11 @@ var antigravityPrefixMapping = []struct {
|
|||||||
target string
|
target string
|
||||||
}{
|
}{
|
||||||
// 长前缀优先
|
// 长前缀优先
|
||||||
{"gemini-3-pro-image", "gemini-3-pro-image"}, // gemini-3-pro-image-preview 等
|
{"gemini-2.5-flash-image", "gemini-3-pro-image"}, // gemini-2.5-flash-image → 3-pro-image
|
||||||
{"claude-3-5-sonnet", "claude-sonnet-4-5"}, // 旧版 claude-3-5-sonnet-xxx
|
{"gemini-3-pro-image", "gemini-3-pro-image"}, // gemini-3-pro-image-preview 等
|
||||||
{"claude-sonnet-4-5", "claude-sonnet-4-5"}, // claude-sonnet-4-5-xxx
|
{"claude-3-5-sonnet", "claude-sonnet-4-5"}, // 旧版 claude-3-5-sonnet-xxx
|
||||||
{"claude-haiku-4-5", "claude-sonnet-4-5"}, // claude-haiku-4-5-xxx → sonnet
|
{"claude-sonnet-4-5", "claude-sonnet-4-5"}, // claude-sonnet-4-5-xxx
|
||||||
|
{"claude-haiku-4-5", "claude-sonnet-4-5"}, // claude-haiku-4-5-xxx → sonnet
|
||||||
{"claude-opus-4-5", "claude-opus-4-5-thinking"},
|
{"claude-opus-4-5", "claude-opus-4-5-thinking"},
|
||||||
{"claude-3-haiku", "claude-sonnet-4-5"}, // 旧版 claude-3-haiku-xxx → sonnet
|
{"claude-3-haiku", "claude-sonnet-4-5"}, // 旧版 claude-3-haiku-xxx → sonnet
|
||||||
{"claude-sonnet-4", "claude-sonnet-4-5"},
|
{"claude-sonnet-4", "claude-sonnet-4-5"},
|
||||||
@@ -310,6 +327,8 @@ func (s *AntigravityGatewayService) unwrapV1InternalResponse(body []byte) ([]byt
|
|||||||
// Forward 转发 Claude 协议请求(Claude → Gemini 转换)
|
// Forward 转发 Claude 协议请求(Claude → Gemini 转换)
|
||||||
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
sessionID := getSessionID(c)
|
||||||
|
prefix := logPrefix(sessionID, account.Name)
|
||||||
|
|
||||||
// 解析 Claude 请求
|
// 解析 Claude 请求
|
||||||
var claudeReq antigravity.ClaudeRequest
|
var claudeReq antigravity.ClaudeRequest
|
||||||
@@ -364,10 +383,11 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if attempt < antigravityMaxRetries {
|
if attempt < antigravityMaxRetries {
|
||||||
log.Printf("Antigravity account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, antigravityMaxRetries, err)
|
log.Printf("%s status=request_failed retry=%d/%d error=%v", prefix, attempt, antigravityMaxRetries, err)
|
||||||
sleepAntigravityBackoff(attempt)
|
sleepAntigravityBackoff(attempt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("%s status=request_failed retries_exhausted error=%v", prefix, err)
|
||||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
|
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,13 +396,13 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
if attempt < antigravityMaxRetries {
|
if attempt < antigravityMaxRetries {
|
||||||
log.Printf("Antigravity account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, antigravityMaxRetries)
|
log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries)
|
||||||
sleepAntigravityBackoff(attempt)
|
sleepAntigravityBackoff(attempt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 所有重试都失败,标记限流状态
|
// 所有重试都失败,标记限流状态
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody)
|
||||||
}
|
}
|
||||||
// 最后一次尝试也失败
|
// 最后一次尝试也失败
|
||||||
resp = &http.Response{
|
resp = &http.Response{
|
||||||
@@ -400,7 +420,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
// 处理错误响应
|
// 处理错误响应
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody)
|
||||||
|
|
||||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
@@ -419,6 +439,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
if claudeReq.Stream {
|
if claudeReq.Stream {
|
||||||
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
|
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("%s status=stream_error error=%v", prefix, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
usage = streamRes.usage
|
usage = streamRes.usage
|
||||||
@@ -443,6 +464,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
// ForwardGemini 转发 Gemini 协议请求
|
// ForwardGemini 转发 Gemini 协议请求
|
||||||
func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) {
|
func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
sessionID := getSessionID(c)
|
||||||
|
prefix := logPrefix(sessionID, account.Name)
|
||||||
|
|
||||||
if strings.TrimSpace(originalModel) == "" {
|
if strings.TrimSpace(originalModel) == "" {
|
||||||
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing model in URL")
|
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing model in URL")
|
||||||
@@ -518,10 +541,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if attempt < antigravityMaxRetries {
|
if attempt < antigravityMaxRetries {
|
||||||
log.Printf("Antigravity account %d: upstream request failed, retry %d/%d: %v", account.ID, attempt, antigravityMaxRetries, err)
|
log.Printf("%s status=request_failed retry=%d/%d error=%v", prefix, attempt, antigravityMaxRetries, err)
|
||||||
sleepAntigravityBackoff(attempt)
|
sleepAntigravityBackoff(attempt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("%s status=request_failed retries_exhausted error=%v", prefix, err)
|
||||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries")
|
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,13 +554,13 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
if attempt < antigravityMaxRetries {
|
if attempt < antigravityMaxRetries {
|
||||||
log.Printf("Antigravity account %d: upstream status %d, retry %d/%d", account.ID, resp.StatusCode, attempt, antigravityMaxRetries)
|
log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries)
|
||||||
sleepAntigravityBackoff(attempt)
|
sleepAntigravityBackoff(attempt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 所有重试都失败,标记限流状态
|
// 所有重试都失败,标记限流状态
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody)
|
||||||
}
|
}
|
||||||
resp = &http.Response{
|
resp = &http.Response{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
@@ -558,7 +582,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
// 处理错误响应
|
// 处理错误响应
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody)
|
||||||
|
|
||||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
@@ -580,6 +604,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
if stream || upstreamAction == "streamGenerateContent" {
|
if stream || upstreamAction == "streamGenerateContent" {
|
||||||
streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime)
|
streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("%s status=stream_error error=%v", prefix, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
usage = streamRes.usage
|
usage = streamRes.usage
|
||||||
@@ -628,7 +653,7 @@ func sleepAntigravityBackoff(attempt int) {
|
|||||||
sleepGeminiBackoff(attempt) // 复用 Gemini 的退避逻辑
|
sleepGeminiBackoff(attempt) // 复用 Gemini 的退避逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, body []byte) {
|
func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte) {
|
||||||
// 429 使用 Gemini 格式解析(从 body 解析重置时间)
|
// 429 使用 Gemini 格式解析(从 body 解析重置时间)
|
||||||
if statusCode == 429 {
|
if statusCode == 429 {
|
||||||
resetAt := ParseGeminiRateLimitResetTime(body)
|
resetAt := ParseGeminiRateLimitResetTime(body)
|
||||||
@@ -639,17 +664,23 @@ func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, acc
|
|||||||
defaultDur = 5 * time.Minute
|
defaultDur = 5 * time.Minute
|
||||||
}
|
}
|
||||||
ra := time.Now().Add(defaultDur)
|
ra := time.Now().Add(defaultDur)
|
||||||
|
log.Printf("%s status=429 rate_limited reset_in=%v (fallback)", prefix, defaultDur)
|
||||||
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
|
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Unix(*resetAt, 0))
|
resetTime := time.Unix(*resetAt, 0)
|
||||||
|
log.Printf("%s status=429 rate_limited reset_at=%v reset_in=%v", prefix, resetTime.Format("15:04:05"), time.Until(resetTime).Truncate(time.Second))
|
||||||
|
_ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 其他错误码继续使用 rateLimitService
|
// 其他错误码继续使用 rateLimitService
|
||||||
if s.rateLimitService == nil {
|
if s.rateLimitService == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body)
|
shouldDisable := s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body)
|
||||||
|
if shouldDisable {
|
||||||
|
log.Printf("%s status=%d marked_error", prefix, statusCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type antigravityStreamResult struct {
|
type antigravityStreamResult struct {
|
||||||
@@ -758,7 +789,7 @@ func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int,
|
|||||||
|
|
||||||
func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, upstreamStatus int, body []byte) error {
|
func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, upstreamStatus int, body []byte) error {
|
||||||
// 记录上游错误详情便于调试
|
// 记录上游错误详情便于调试
|
||||||
log.Printf("Antigravity upstream error %d: %s", upstreamStatus, string(body))
|
log.Printf("[antigravity-Forward] upstream_error status=%d body=%s", upstreamStatus, string(body))
|
||||||
|
|
||||||
var statusCode int
|
var statusCode int
|
||||||
var errType, errMsg string
|
var errType, errMsg string
|
||||||
@@ -832,7 +863,7 @@ func (s *AntigravityGatewayService) handleClaudeNonStreamingResponse(c *gin.Cont
|
|||||||
// 转换 Gemini 响应为 Claude 格式
|
// 转换 Gemini 响应为 Claude 格式
|
||||||
claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(body, originalModel)
|
claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(body, originalModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Transform Gemini to Claude failed: %v, body: %s", err, string(body))
|
log.Printf("[antigravity-Forward] transform_error error=%v body=%s", err, string(body))
|
||||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
|
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -171,6 +172,15 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antigravity 账户:不可重试错误直接标记 error 状态并返回
|
||||||
|
if account.Platform == PlatformAntigravity && isNonRetryableRefreshError(err) {
|
||||||
|
errorMsg := fmt.Sprintf("Token refresh failed (non-retryable): %v", err)
|
||||||
|
if setErr := s.accountRepo.SetError(ctx, account.ID, errorMsg); setErr != nil {
|
||||||
|
log.Printf("[TokenRefresh] Failed to set error status for account %d: %v", account.ID, setErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
log.Printf("[TokenRefresh] Account %d attempt %d/%d failed: %v",
|
log.Printf("[TokenRefresh] Account %d attempt %d/%d failed: %v",
|
||||||
account.ID, attempt, s.cfg.MaxRetries, err)
|
account.ID, attempt, s.cfg.MaxRetries, err)
|
||||||
@@ -183,11 +193,37 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有重试都失败,标记账号为error状态
|
// Antigravity 账户:其他错误仅记录日志,不标记 error(可能是临时网络问题)
|
||||||
errorMsg := fmt.Sprintf("Token refresh failed after %d retries: %v", s.cfg.MaxRetries, lastErr)
|
// 其他平台账户:重试失败后标记 error
|
||||||
if err := s.accountRepo.SetError(ctx, account.ID, errorMsg); err != nil {
|
if account.Platform == PlatformAntigravity {
|
||||||
log.Printf("[TokenRefresh] Failed to set error status for account %d: %v", account.ID, err)
|
log.Printf("[TokenRefresh] Account %d: refresh failed after %d retries: %v", account.ID, s.cfg.MaxRetries, lastErr)
|
||||||
|
} else {
|
||||||
|
errorMsg := fmt.Sprintf("Token refresh failed after %d retries: %v", s.cfg.MaxRetries, lastErr)
|
||||||
|
if err := s.accountRepo.SetError(ctx, account.ID, errorMsg); err != nil {
|
||||||
|
log.Printf("[TokenRefresh] Failed to set error status for account %d: %v", account.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isNonRetryableRefreshError 判断是否为不可重试的刷新错误
|
||||||
|
// 这些错误通常表示凭证已失效,需要用户重新授权
|
||||||
|
func isNonRetryableRefreshError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
nonRetryable := []string{
|
||||||
|
"invalid_grant", // refresh_token 已失效
|
||||||
|
"invalid_client", // 客户端配置错误
|
||||||
|
"unauthorized_client", // 客户端未授权
|
||||||
|
"access_denied", // 访问被拒绝
|
||||||
|
}
|
||||||
|
for _, needle := range nonRetryable {
|
||||||
|
if strings.Contains(msg, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user