From 34d04e76a9610794709e075bad1c64b3a02151e1 Mon Sep 17 00:00:00 2001 From: C3H3-AI Date: Tue, 15 Sep 2026 13:04:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=20CI/CD=20=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E6=9E=84=E5=BB=BA=E9=95=9C=E5=83=8F=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=B6=E5=8C=BA/TraeWork=20=E9=A2=84?= =?UTF-8?q?=E7=83=AD/=E6=B5=81=E5=BC=8F=E6=88=AA=E6=96=AD=E4=B8=89?= =?UTF-8?q?=E9=A1=B9=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI/CD(新增): - go-ci: go build / vet / test 门禁,此前仓库无任何 CI, TECHNICAL-DOC 的审计本身是在无 Go 工具链下做的静态审阅 - build-image: 用 HA 官方 builder actions 预构建 amd64/aarch64 镜像推 GHCR, config.yaml 增加 image 字段,Supervisor 改为直接拉镜像而非在用户设备编译 - release: 读 config.yaml version 做 semver 比较;tag 先建再发草稿 release (GitHub 对草稿不创建 tag,会导致用户收不到更新); 末尾用 git ls-remote 校验 tag 存在,把静默失败变为显式失败 - pr-validate / pr-label: 分支来源、标题格式、描述完整性、变更类型唯一 - stale / sync-labels: issue 清理与标签同步 修复: - Dockerfile 补 ENV TZ=Asia/Shanghai:容器默认 UTC 使配置的 09:00 实际在 17:00(北京时间)触发签到与保活 - TraeWork 预刷新窗口 10min -> 24h(按平台区分,WorkBuddy 保持 10min): 窗口过窄时请求途中 token 失效会返回 401 -> ErrSessionDead -> 账号被永久禁用 - upstream 流式请求改用无总时长上限的 client:http.Client.Timeout 含读取 响应体时间,长回答超过 120s 会被中途截断;首字节改由 ResponseHeaderTimeout 约束 - traework FetchModels 过滤 is_custom_model 自定义模型(对齐上游 abb9b0b): 第三方代理模型需额外授权,出现在 /v1/models 会导致用户选中后必然失败 其他: - 移除 build.yaml:legacy builder 配置,Supervisor 2026.04.0 起不再被读取, 其 build_from / args.BUILD_VERSION 均为死配置,易误导 - 修正 TestModelsDynamic:v1.1.0b11 起 /v1/models 追加虚拟 cheapest 条目, 测试仍断言 3 个模型导致长期失败(改动前基线即失败) - PR 模板字段与 pr-validate 门禁对齐(原 What/Why 会被 CI 拒绝) --- .github/PULL_REQUEST_TEMPLATE.md | 63 +++++++++++-- .github/labels.yml | 70 ++++++++++++++ .github/release.yml | 25 +++++ .github/scripts/read_addon_version.py | 40 ++++++++ .github/workflows/build-image.yml | 129 ++++++++++++++++++++++++++ .github/workflows/go-ci.yml | 47 ++++++++++ .github/workflows/pr-label.yml | 113 ++++++++++++++++++++++ .github/workflows/pr-validate.yml | 82 ++++++++++++++++ .github/workflows/release.yml | 127 +++++++++++++++++++++++++ .github/workflows/stale.yml | 55 +++++++++++ .github/workflows/sync-labels.yml | 24 +++++ Dockerfile | 4 + README.md | 10 +- SYNC.md | 7 +- TECHNICAL-DOC.md | 4 +- build.yaml | 6 -- config.yaml | 4 + src/internal/server/handler.go | 29 +++++- src/internal/server/handler_test.go | 34 ++++++- src/internal/traework/client.go | 9 +- src/internal/traework/client_test.go | 43 +++++++++ src/internal/upstream/client.go | 18 +++- src/internal/upstream/client_test.go | 31 +++++++ 23 files changed, 950 insertions(+), 24 deletions(-) create mode 100644 .github/labels.yml create mode 100644 .github/release.yml create mode 100755 .github/scripts/read_addon_version.py create mode 100644 .github/workflows/build-image.yml create mode 100644 .github/workflows/go-ci.yml create mode 100644 .github/workflows/pr-label.yml create mode 100644 .github/workflows/pr-validate.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/sync-labels.yml delete mode 100644 build.yaml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e6d4034..74e3ad9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,62 @@ -## What +## Description / 变更描述 - + -## Why - +## Type of change / 变更类型 -## Tested +- [ ] bugfix: Bug fix / 问题修复 +- [ ] new feature: New feature / 新功能 +- [ ] refactor: Code refactoring and optimization / 重构与优化 +- [ ] breaking: Breaking change / 破坏性变更 +- [ ] docs: Documentation / 文档更新 - +## Testing / 测试 -## Checklist + -- [ ] code style / CI green +- HA Version / HA版本: +- Add-on Version / 加载项版本: +- Test Result / 测试结果: + +## Checklist / 自检清单 + +- [ ] Code follows project standards / 代码符合项目规范 +- [ ] No debug code or unrelated files / 无调试代码或无关文件 + + diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..dc52e65 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,70 @@ +# GitHub Labels —— 由 .github/workflows/sync-labels.yml 推送到仓库。 +# +# 带 emoji 的 5 个是「变更类型」标签,必须与以下两处严格一致: +# - .github/workflows/pr-label.yml (打标) +# - .github/release.yml (release notes 分类) +# 改名字要三处同改,否则 release notes 分类会漏。 +- name: "✨ Feature" + color: a2eeef + description: New feature / 新功能 + +- name: "🐛 Bug Fix" + color: d73a4a + description: Bug fix / 问题修复 + +- name: "🔧 Refactor" + color: 7057ff + description: Code refactoring and optimization / 重构与优化 + +- name: "⚠️ Breaking" + color: e4e669 + description: Breaking change / 破坏性变更 + +- name: "📝 Docs" + color: 0075ca + description: Documentation / 文档更新 + +# === 以下为 GitHub 默认标签(保留,带中文说明)=== +- name: bug + color: d73a4a + description: Something isn't working / 出现了问题或功能异常 + +- name: documentation + color: 0075ca + description: Improvements or additions to documentation / 文档改进或补充 + +- name: duplicate + color: cfd3d7 + description: This issue or pull request already exists / 重复的问题或拉取请求 + +- name: enhancement + color: a2eeef + description: New feature or request / 新功能或改进请求 + +- name: good first issue + color: 7057ff + description: Good for newcomers / 适合新贡献者的简单问题 + +- name: help wanted + color: 008672 + description: Extra attention is needed / 需要额外帮助或关注 + +- name: invalid + color: e4e669 + description: This doesn't seem right / 内容不正确 + +- name: question + color: d876e3 + description: Further information is requested / 需要更多信息 + +- name: wontfix + color: ffffff + description: This will not be worked on / 不会处理 + +- name: stale + color: eeeeee + description: Inactive issue / 长期无活动的问题 + +- name: stale-closed + color: eeeeee + description: Closed due to inactivity / 因长期无活动而关闭 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..f83a62e --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,25 @@ +changelog: + categories: + - title: "✨ Features / 新功能" + labels: + - "✨ Feature" + + - title: "🐛 Bug Fixes / 问题修复" + labels: + - "🐛 Bug Fix" + + - title: "🔧 Refactor / 重构" + labels: + - "🔧 Refactor" + + - title: "📝 Documentation / 文档" + labels: + - "📝 Docs" + + - title: "⚠️ Breaking Changes / 破坏性变更" + labels: + - "⚠️ Breaking" + + - title: "Other Changes / 其他" + labels: + - "*" diff --git a/.github/scripts/read_addon_version.py b/.github/scripts/read_addon_version.py new file mode 100755 index 0000000..01b4a00 --- /dev/null +++ b/.github/scripts/read_addon_version.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""从 HA add-on 的 config.yaml 读取 version 字段并打印。 + +单独成文件而非内联 `python3 -c`:内联时引号要同时穿过 +YAML 块标量 + shell 双引号 + Python 字符串三层,实测会因 +['\\''] 这类写法直接 SyntaxError(已在本地复现)。 + +用法: read_addon_version.py +退出码: 0 = 成功打印版本;1 = 找不到 version 字段 +""" +import re +import sys + +# 兼容 version: "1.1.0b13" / version: '1.1.0' / version: 1.2.3 / 多余空格 +PATTERN = re.compile(r"""^version:\s*["']?([^"'\s#]+)""") + + +def read_version(path: str) -> str: + with open(path, encoding="utf-8") as fh: + for line in fh: + match = PATTERN.match(line) + if match: + return match.group(1) + return "" + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: read_addon_version.py ", file=sys.stderr) + return 1 + version = read_version(sys.argv[1]) + if not version: + print(f"no version field found in {sys.argv[1]}", file=sys.stderr) + return 1 + print(version) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..93777a3 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,129 @@ +name: Build Add-on Image + +# 预构建镜像(HA 官方推荐的发布方式)。 +# +# 现状对比: +# 现在 = Supervisor 在用户设备上编译(拉 golang 镜像 + 编 5 个 Go 二进制), +# 树莓派上可能十几分钟,网络抖动即失败。官方称此法应"migrate away"。 +# 本流程 = 在 CI 编译 amd64 + aarch64,推到 ghcr.io,用户只下载镜像。 +# +# 用 HA 官方 builder actions(home-assistant/builder),而非手写 buildx: +# 它会自动注入 BUILD_ARCH / BUILD_VERSION,并写入 io.hass.* 标签。 +on: + push: + branches: [main, master] + paths: + - 'config.yaml' + - 'Dockerfile' + - 'src/**' + - 'run.sh' + - 'login_ui.py' + - '.github/workflows/build-image.yml' + workflow_dispatch: + inputs: + version: + description: '要构建的版本(留空则读 config.yaml)' + required: false + type: string + +permissions: + contents: read + packages: write + id-token: write + +env: + ARCHITECTURES: '["amd64", "aarch64"]' + IMAGE_NAME: ai-proxy + +jobs: + init: + name: Resolve version & matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: version + run: | + set -euo pipefail + if [ -n "${{ inputs.version }}" ]; then + VER="${{ inputs.version }}" + else + VER="$(python3 .github/scripts/read_addon_version.py config.yaml)" + fi + echo "version=${VER}" >> $GITHUB_OUTPUT + echo "Building version: ${VER}" + + - name: Get build matrix + id: matrix + uses: home-assistant/builder/actions/prepare-multi-arch-matrix@7.2.0 + with: + architectures: ${{ env.ARCHITECTURES }} + image-name: ${{ env.IMAGE_NAME }} + + build: + name: Build ${{ matrix.arch }} + needs: init + runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + packages: write + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.init.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + + - name: Build image + uses: home-assistant/builder/actions/build-image@7.2.0 + with: + arch: ${{ matrix.arch }} + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + image: ${{ matrix.image }} + image-tags: | + ${{ needs.init.outputs.version }} + latest + push: "true" + version: ${{ needs.init.outputs.version }} + + manifest: + name: Publish multi-arch manifest + needs: [init, build] + runs-on: ubuntu-latest + permissions: + id-token: write + packages: write + steps: + - name: Publish multi-arch manifest + uses: home-assistant/builder/actions/publish-multi-arch-manifest@7.2.0 + with: + architectures: ${{ env.ARCHITECTURES }} + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + image-name: ${{ env.IMAGE_NAME }} + image-tags: | + ${{ needs.init.outputs.version }} + latest + + # 关键校验:config.yaml 的 version 必须与镜像 tag 一致。 + # 官方文档明确:image 模式下 version "needs to match the tag of the image"。 + # 不一致 = 用户拉不到镜像,且是静默的——所以在这里显式失败。 + verify: + name: Verify image reachable + needs: [init, manifest] + runs-on: ubuntu-latest + steps: + - name: Verify manifest tag matches config.yaml version + run: | + set -euo pipefail + IMAGE="ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}" + VERSION="${{ needs.init.outputs.version }}" + echo "Checking ${IMAGE}:${VERSION}" + if ! docker manifest inspect "${IMAGE}:${VERSION}" >/dev/null 2>&1; then + echo "::error::Image ${IMAGE}:${VERSION} not found — config.yaml version 与镜像 tag 不一致,用户将无法安装" + exit 1 + fi + echo "Image ${IMAGE}:${VERSION} is reachable" diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..4307e03 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,47 @@ +name: go-ci + +# 门禁:编译 + 静态检查 + 单测。 +# 此前仓库无任何 CI,TECHNICAL-DOC 的审计本身是在「无 Go 工具链」下做的静态审阅, +# 改动无法自动验证。本 workflow 补上这门禁。 +on: + push: + branches: [master, dev] + paths: + - 'src/**' + - 'Dockerfile' + - '.github/workflows/go-ci.yml' + pull_request: + paths: + - 'src/**' + - 'Dockerfile' + - '.github/workflows/go-ci.yml' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + cache-dependency-path: src/go.sum + + - name: go build + run: go build ./... + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + # addon 的构建路径与本地 go build 不同(5 个二进制 + 多架构), + # 单独确认 Dockerfile 仍能编译,避免只在 HA 构建时才发现。 + - name: docker build (addon 构建路径) + working-directory: . + run: docker build --build-arg BUILD_ARCH=amd64 --build-arg BUILD_VERSION=ci . diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml new file mode 100644 index 0000000..288d90a --- /dev/null +++ b/.github/workflows/pr-label.yml @@ -0,0 +1,113 @@ +name: PR Label + +on: + pull_request_target: + types: [opened, edited, synchronize] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + label: + runs-on: ubuntu-latest + + steps: + - name: Detect change type and sync label + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + shell: bash + run: | + + echo "Checking change type" + + + PR_TYPE=$(echo "$PR_BODY" \ + | grep -E '\[x\].*(bugfix|new feature|refactor|breaking|docs):' \ + | sed -E 's/.*\] ([^:]+):.*/\1/') + + + if [[ -z "$PR_TYPE" ]]; then + echo "❌ No change type selected" + exit 1 + fi + + + echo "Detected type: $PR_TYPE" + + + case "$PR_TYPE" in + bugfix) + LABEL="🐛 Bug Fix" + ;; + "new feature") + LABEL="✨ Feature" + ;; + refactor) + LABEL="🔧 Refactor" + ;; + breaking) + LABEL="⚠️ Breaking" + ;; + docs) + LABEL="📝 Docs" + ;; + *) + echo "❌ Invalid type: $PR_TYPE" + exit 1 + ;; + esac + + + echo "Target label: $LABEL" + + + CURRENT_LABELS=$(gh pr view "$PR_NUMBER" \ + --repo "$REPO" \ + --json labels \ + -q '.labels[].name') + + + if echo "$CURRENT_LABELS" | grep -Fxq "$LABEL"; then + echo "✅ Label already exists: $LABEL" + exit 0 + fi + + + echo "Updating labels" + + + OLD_LABELS=( + "🐛 Bug Fix" + "✨ Feature" + "🔧 Refactor" + "⚠️ Breaking" + "📝 Docs" + ) + + + for OLD_LABEL in "${OLD_LABELS[@]}"; do + if echo "$CURRENT_LABELS" | grep -Fxq "$OLD_LABEL"; then + echo "Removing old label: $OLD_LABEL" + + gh pr edit "$PR_NUMBER" \ + --repo "$REPO" \ + --remove-label "$OLD_LABEL" + fi + done + + + echo "Adding label: $LABEL" + + + gh pr edit "$PR_NUMBER" \ + --repo "$REPO" \ + --add-label "$LABEL" + + + echo "✅ Label updated: $LABEL" diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml new file mode 100644 index 0000000..1f2a460 --- /dev/null +++ b/.github/workflows/pr-validate.yml @@ -0,0 +1,82 @@ +name: PR Validate + +# PR 门禁:分支来源 / 标题格式 / 正文完整性 / 变更类型唯一。 +# 移植自 hacs-vision 的 pr-validate.yml(规则已在那边跑通)。 +on: + pull_request: + types: [opened, edited, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + validate: + runs-on: ubuntu-latest + steps: + # 禁止从 main/master 直接提 PR(那等于绕过 review 直接进主干) + - name: Validate branch + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + echo "Checking branch: $BRANCH" + if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then + echo "::error::Direct PR from main/master is forbidden" + gh pr close "$PR_NUMBER" --comment "禁止从 main/master 提交 PR / Direct PR from main/master is forbidden." + exit 1 + fi + echo "Branch passed" + + # 标题须为空则拒;含冒号时须为 : + - name: Validate PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + shell: bash + run: | + echo "Checking PR title" + if [[ -z "$PR_TITLE" ]]; then + echo "::error::PR title is empty" + exit 1 + fi + if [[ "$PR_TITLE" == *:* ]]; then + if [[ ! "$PR_TITLE" =~ ^[^:]+:[[:space:]]*.+ ]]; then + echo "::error::Invalid PR title format. Expected: : " + exit 1 + fi + fi + echo "PR title passed" + + # 正文须有 Description 段,且变更类型勾选恰好一项 + - name: Validate PR content + env: + PR_BODY: ${{ github.event.pull_request.body }} + shell: bash + run: | + echo "Checking PR content" + if [[ -z "$PR_BODY" ]]; then + echo "::error::PR body is empty" + exit 1 + fi + + DESCRIPTION=$(echo "$PR_BODY" \ + | sed '//d' \ + | awk '/^## Description/ {flag=1; next} /^## / {flag=0} flag' \ + | sed '/^[[:space:]]*$/d') + if [[ -z "$DESCRIPTION" ]]; then + echo "::error::Description section is empty" + exit 1 + fi + echo "Description passed" + + TYPE_COUNT=$(echo "$PR_BODY" | grep -Ec '\[x\].*(bugfix|new feature|refactor|breaking|docs):') + if [[ "$TYPE_COUNT" -ne 1 ]]; then + echo "::error::Select exactly one change type (found $TYPE_COUNT)" + exit 1 + fi + PR_TYPE=$(echo "$PR_BODY" \ + | grep -E '\[x\].*(bugfix|new feature|refactor|breaking|docs):' \ + | sed -E 's/.*\] ([^:]+):.*/\1/') + echo "Change type: $PR_TYPE" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..189036a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +name: Release + +# 发版流程(add-on 版)。 +# +# 与 hacs-vision(HACS integration)的关键差异: +# - 版本号读 config.yaml 的 version,不是 custom_components/*/manifest.json +# - **不打包 zip 资产**:add-on 的安装单元是整个仓库,Supervisor 直接拉代码/ +# 镜像,不会去下载 Release 上的 zip。zip 步骤在这里是死代码。 +# +# 保留 hacs-vision 用 v7.0.0 换来的两条教训: +# 1. GitHub 对草稿 Release 不创建 tag(只给 untagged- 占位 URL)。 +# tag 不存在 → 发布后依然没 tag → 用户收不到更新。必须先建 tag。 +# 2. 判断 tag 是否存在必须查 git ls-remote,不能看 Release 的 URL。 +on: + push: + branches: [main, master] + paths: + - 'config.yaml' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # 读版本、与最新 tag 做 semver 比较(严格大于才发版) + - name: Version check + id: check + run: | + set -euo pipefail + NEW_VERSION="$(python3 .github/scripts/read_addon_version.py config.yaml)" + if [ -z "$NEW_VERSION" ]; then + echo "::error::cannot read version from config.yaml" + exit 1 + fi + + LATEST_TAG=$(git tag -l "v*" --sort=-v:refname | head -n 1) + [ -z "$LATEST_TAG" ] && LATEST_TAG="v0.0.0" + OLD_VERSION=${LATEST_TAG#v} + + echo "new=$NEW_VERSION old=$OLD_VERSION" + + python3 -m pip install --quiet packaging + + # add-on 版本常带 b13 / beta 这类后缀(非标准 PEP440 版次), + # packaging 能解析 1.1.0b13;解析失败则视为非法版本,直接失败。 + IS_INCREASED=$(python3 -c " + from packaging import version + try: + print(version.parse('$NEW_VERSION') > version.parse('$OLD_VERSION')) + except Exception as e: + print('PARSE_ERROR', e); raise SystemExit(1) + ") + + if [ "$IS_INCREASED" = "True" ]; then + echo "compare_result=success" >> $GITHUB_OUTPUT + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + if [[ "$NEW_VERSION" == *beta* || "$NEW_VERSION" == *rc* || "$NEW_VERSION" == *alpha* || "$NEW_VERSION" == *b[0-9]* ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + else + echo "compare_result=skip" >> $GITHUB_OUTPUT + echo "::notice::新版本 $NEW_VERSION 不大于 $OLD_VERSION,跳过发布" + fi + + # tag 必须先建:草稿 Release 不会创建 tag + - name: Create and push tag + if: steps.check.outputs.compare_result == 'success' + run: | + set -euo pipefail + TAG="v${{ steps.check.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping" + else + git tag "$TAG" + git push origin "$TAG" + echo "Created tag $TAG" + fi + + - name: Create draft release + if: steps.check.outputs.compare_result == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.check.outputs.version }} + IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }} + run: | + set -euo pipefail + TAG="v${VERSION}" + COMMIT_TITLE=$(git log -1 --pretty=format:"%s") + + PRERELEASE_FLAG="" + [[ "${IS_PRERELEASE}" == "true" ]] && PRERELEASE_FLAG="--prerelease" + + # 已存在同名 release 时跳过,避免 workflow 重跑报错 + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists, skipping" + exit 0 + fi + + gh release create "${TAG}" \ + --draft \ + --target "${{ github.sha }}" \ + --title "${TAG} - ${COMMIT_TITLE}" \ + --generate-notes \ + ${PRERELEASE_FLAG} + + echo "Draft release created for ${TAG}" + + # 把静默失败变成显式失败:否则要等用户反馈"收不到更新"才发现 + - name: Verify tag exists + if: steps.check.outputs.compare_result == 'success' + run: | + set -euo pipefail + TAG="v${{ steps.check.outputs.version }}" + if ! git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "::error::Tag ${TAG} was not created — 用户将收不到更新" + exit 1 + fi + echo "Tag ${TAG} exists" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..d944b0b --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,55 @@ +name: Close Stale Issues + +# 自动标记长期无活动的 issue,并在持续无活动后关闭。 +# 计划:每天 UTC 01:30 运行。 +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: + +permissions: + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + # === 时间设置 === + days-before-stale: 15 + days-before-close: 5 + # 只处理 issue,不处理 PR + days-before-pr-stale: -1 + days-before-pr-close: -1 + + # === 豁免标签 === + # bug 类问题不自动关闭(可能是真问题,只是没人跟) + exempt-issue-labels: "bug,🐛 Bug Fix" + + # === 要添加的标签 === + stale-issue-label: stale + close-issue-label: stale-closed + + # === 消息(双语)=== + stale-issue-message: >- + This issue has been inactive for 15 days and is being marked as stale. + 该问题已 15 天无活动,正在标记为过期。 + + If this issue is still relevant, please leave a comment to keep it open. + 如果该问题仍然相关,请留言以保持其开启状态。 + + Otherwise, it will be closed in 5 days. + 否则,将在 5 天后自动关闭。 + + close-issue-message: >- + This issue was closed due to prolonged inactivity after being marked stale. + 该问题在标记为过期后因持续无活动而被关闭。 + + If you believe this was closed in error, please reopen it with a comment. + 如果您认为这是错误关闭的,请留言并重新打开。 + + # === 行为设置 === + remove-stale-when-updated: true + # 每次运行最大操作数(避免触发 API 速率限制) + operations-per-run: 30 diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 0000000..3ac56c1 --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,24 @@ +name: Sync Labels + +# 手动触发:把 .github/labels.yml 推送到仓库标签。 +# 新仓库首次启用时跑一次;之后改 labels.yml 再跑。 +on: + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + sync-labels: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Sync labels + uses: crazy-max/ghaction-github-labeler@v5 + with: + yaml-file: .github/labels.yml + dry-run: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index bf29d3f..5c25ce0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,10 @@ FROM docker.io/library/alpine:3.20 ARG BUILD_VERSION ARG BUILD_ARCH +# 时区:容器默认 UTC 会让 scheduler 里 time.Now() 取到 UTC, +# 使 options 中配置的 09:00 实际在 17:00(北京时间)触发签到 / 保活。 +ENV TZ=Asia/Shanghai + LABEL \ io.hass.version="${BUILD_VERSION}" \ io.hass.arch="${BUILD_ARCH}" \ diff --git a/README.md b/README.md index 02dd11f..68dfff7 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,15 @@ make vet # 静态检查 ## 构建 -多架构(amd64 / aarch64)由 HA add-on 构建系统按 `build.yaml` + `Dockerfile` 自动完成。 +多架构(amd64 / aarch64)镜像由 GitHub Actions 用 HA 官方 builder actions +预构建并推送到 `ghcr.io/c3h3-ai/ai-proxy`,见 `.github/workflows/build-image.yml`。 +Supervisor 直接拉镜像,不在用户设备上编译。 + +> `build.yaml` 已移除:它是 legacy builder 的配置,自 Supervisor 2026.04.0 起 +> **不再被读取**(官方文档:*"This file is no longer used"*)。基础镜像由 +> Dockerfile 的 `FROM` 决定,`BUILD_VERSION` / `BUILD_ARCH` 由 Supervisor +> 按 `config.yaml` 的 `version` 自动注入。 + 本地验证: ```bash diff --git a/SYNC.md b/SYNC.md index 7a324d3..314702f 100644 --- a/SYNC.md +++ b/SYNC.md @@ -87,4 +87,9 @@ cd D:/ai-hub/integrations/ha-ai-proxy/src go build ./... && go vet ./internal/... ``` -构建由 HA addon 的 `build.yaml` + `Dockerfile` 多架构自动化完成。 +构建由 GitHub Actions 用 HA 官方 builder actions 完成多架构(amd64/aarch64) +预构建并推送 GHCR,见 `.github/workflows/build-image.yml`。 + +> 注意:`sync_vendor.sh` 的 `PROTECT_FILES` 目前**未包含** `handler.go`、 +> `traework/client.go`、`upstream/client.go`。若在这些文件里做了 addon 侧修复, +> 跑 sync 前先确认它们已被保护,否则会被上游版本静默覆盖。 diff --git a/TECHNICAL-DOC.md b/TECHNICAL-DOC.md index fea79fd..d7873cf 100644 --- a/TECHNICAL-DOC.md +++ b/TECHNICAL-DOC.md @@ -81,7 +81,7 @@ ha-ai-proxy/ ├── config.yaml HA addon 清单(名称/端口/ingress/options/schema) ├── schema.yaml options 字段类型定义(与 config.yaml schema 重复) -├── build.yaml HA addon 构建描述(aarch64/amd64,golang:1.25-alpine) +(build.yaml 已移除:legacy builder 配置,Supervisor 2026.04.0 起不再读取) ├── Dockerfile 多阶段构建:golang:1.25-alpine 编译 4 个二进制 + alpine 运行时 ├── run.sh 入口:建持久化目录 → exec python3 login_ui.py ├── login_ui.py 管理面板 + OpenAI API 统一入口(Python 3) @@ -330,7 +330,7 @@ Web UI 为单文件内嵌 HTML(暗色主题),含概览/账号/模型/设 ## 11. 部署形态 - **形态**:HA addon(Supervisor 托管)。`config.yaml` 含 `hassio_api`/`ingress`/`panel_icon` 等字段。 -- **构建**:`build.yaml` 双架构(aarch64→arm64 / amd64);Dockerfile 多阶段:golang:1.25-alpine 编译 4 个二进制 → alpine:3.20 运行时(bash/curl/jq/python3/ca-certificates/tzdata)。 +- **构建**:GitHub Actions + HA 官方 builder actions 预构建多架构镜像推 GHCR(`ghcr.io/c3h3-ai/ai-proxy`),Supervisor 直接拉取;Dockerfile 多阶段:golang:1.25-alpine 编译 5 个二进制 → alpine:3.20 运行时(bash/curl/jq/python3/ca-certificates/tzdata)。 - **启动**:`run.sh` → `python3 login_ui.py`(PID 1)→ 拉起 `serverd`。 - **持久化**:`/data/auths`(凭证,rw)、`/data/data`(state.json、options.json、config.json)。 - **健康检查**:`wget http://127.0.0.1:7863/healthz`。 diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 5eea8a0..0000000 --- a/build.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -build_from: - aarch64: "docker.io/library/golang:1.25-alpine" - amd64: "docker.io/library/golang:1.25-alpine" -args: - BUILD_VERSION: "1.1.0b13" \ No newline at end of file diff --git a/config.yaml b/config.yaml index 202dbdb..b313fd7 100644 --- a/config.yaml +++ b/config.yaml @@ -3,6 +3,10 @@ version: "1.1.0b13" slug: "ai-proxy" description: "多平台 AI 账号(WorkBuddy/CodeBuddy + TraeWork)聚合为 OpenAI 兼容 API,模型带来源前缀自动路由;自动签到、多账号轮转。" url: "https://github.com/C3H3-AI/ai-proxy" +# 预构建镜像:由 .github/workflows/build-image.yml 编译并推送到 GHCR。 +# 设置后 Supervisor 直接拉镜像,不再在用户设备上编译(官方推荐做法)。 +# 注意:此 tag 必须与上面的 version 完全一致,否则用户拉不到镜像。 +image: "ghcr.io/c3h3-ai/ai-proxy" arch: - aarch64 - amd64 diff --git a/src/internal/server/handler.go b/src/internal/server/handler.go index c66f026..5e58baa 100644 --- a/src/internal/server/handler.go +++ b/src/internal/server/handler.go @@ -54,6 +54,31 @@ type Config struct { AutoModels map[provider.Kind][]string } +// defaultRefreshSkew 返回按平台的默认 token 预刷新窗口。 +// +// TraeWork 的 access token 生命周期短且上游可能提前吊销,10 分钟窗口过窄: +// 一旦请求途中 token 失效,上游返回 401 会被 Classify 判为 ErrSessionDead, +// 进而 Pool.Disable 永久禁用账号(需人工重登)。traework2api 用 24h 窗口正是 +// 为此,这里与其对齐。WorkBuddy 侧 10 分钟已足够,保持不变。 +func defaultRefreshSkew(kind provider.Kind) time.Duration { + if kind == provider.TraeWork { + return 24 * time.Hour + } + return 10 * time.Minute +} + +// refreshSkewFor 解析指定平台实际使用的预刷新窗口: +// cfg.RefreshSkew 显式配置时仍以全局值为准(保持旧行为、便于统一调参), +// 未配置(<=0)或低于平台默认值时取平台默认值——窗口只放宽不收紧, +// 避免把 TraeWork 又调回过窄区间。 +func (h *Handler) refreshSkewFor(kind provider.Kind) time.Duration { + base := defaultRefreshSkew(kind) + if h.cfg.RefreshSkew > base { + return h.cfg.RefreshSkew + } + return base +} + // Handler 主路由。 type Handler struct { cfg Config @@ -86,7 +111,7 @@ func NewHandler(cfg Config) *Handler { cfg.ErrCooldown = 10 * time.Minute } if cfg.RefreshSkew <= 0 { - cfg.RefreshSkew = 10 * time.Minute + cfg.RefreshSkew = defaultRefreshSkew(provider.WorkBuddy) } h := &Handler{cfg: cfg, mux: http.NewServeMux(), sticky: make(map[string]*stickyEntry)} h.mux.HandleFunc("POST /v1/chat/completions", h.withAuth(h.chatCompletions)) @@ -430,7 +455,7 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { break } tried[acct.UID] = true - if acct.NeedsRefresh(h.cfg.RefreshSkew) { + if acct.NeedsRefresh(h.refreshSkewFor(rt.Kind)) { log.Printf("refresh start platform=%s uid=%s reason=request", rt.Kind, acct.UID) if err := rt.Upstream.RefreshToken(acct); err != nil { log.Printf("refresh failed platform=%s uid=%s err=%v", rt.Kind, acct.UID, err) diff --git a/src/internal/server/handler_test.go b/src/internal/server/handler_test.go index d4bf5f0..9037564 100644 --- a/src/internal/server/handler_test.go +++ b/src/internal/server/handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/rockswang/workbuddy-wild/internal/auth" "github.com/rockswang/workbuddy-wild/internal/pool" + "github.com/rockswang/workbuddy-wild/internal/provider" "github.com/rockswang/workbuddy-wild/internal/upstream" ) @@ -230,8 +231,9 @@ func TestModelsDynamic(t *testing.T) { var resp map[string]any json.Unmarshal(rec.Body.Bytes(), &resp) data := resp["data"].([]any) - if len(data) != 3 { - t.Fatalf("want 3 dynamic models, got %d: %v", len(data), data) + // 3 个动态模型 + 平台虚拟 cheapest 条目(v1.1.0b11 起在模型列表末尾追加)。 + if len(data) != 4 { + t.Fatalf("want 4 models (3 dynamic + cheapest), got %d: %v", len(data), data) } ids := map[string]bool{} for _, m := range data { @@ -240,6 +242,9 @@ func TestModelsDynamic(t *testing.T) { if !ids["workbuddy/dyn-model-a"] || !ids["workbuddy/glm-9.9"] { t.Errorf("dynamic ids missing: %v", ids) } + if !ids["workbuddy/cheapest"] { + t.Errorf("virtual cheapest entry missing: %v", ids) + } // 断言字段映射:maxInputTokens → context_length,maxOutputTokens → max_output_tokens for _, m := range data { @@ -428,3 +433,28 @@ func TestStatusRequiresAuth(t *testing.T) { t.Errorf("healthz: code=%d", rec.Code) } } + +// TestRefreshSkewPerPlatform 校验预刷新窗口按平台区分: +// TraeWork 必须拿到比 WorkBuddy 宽得多的窗口(24h vs 10min),否则请求途中 +// token 失效会返回 401 → ErrSessionDead → 账号被永久禁用(需人工重登)。 +func TestRefreshSkewPerPlatform(t *testing.T) { + h := NewHandler(Config{Pool: testPoolWith(&auth.Auth{UID: "u1"}), Upstream: newFakeUpstream(t, nil)}) + + if got := h.refreshSkewFor(provider.TraeWork); got != 24*time.Hour { + t.Errorf("traework skew=%v want 24h", got) + } + if got := h.refreshSkewFor(provider.WorkBuddy); got != 10*time.Minute { + t.Errorf("workbuddy skew=%v want 10m", got) + } + + // 显式配置更大的窗口时以配置为准。 + h2 := NewHandler(Config{Pool: testPoolWith(&auth.Auth{UID: "u1"}), Upstream: newFakeUpstream(t, nil), RefreshSkew: 48 * time.Hour}) + if got := h2.refreshSkewFor(provider.WorkBuddy); got != 48*time.Hour { + t.Errorf("configured skew not honored: %v", got) + } + // 配置小于平台默认值时不得收紧(否则会把 TraeWork 调回过窄区间)。 + h3 := NewHandler(Config{Pool: testPoolWith(&auth.Auth{UID: "u1"}), Upstream: newFakeUpstream(t, nil), RefreshSkew: time.Minute}) + if got := h3.refreshSkewFor(provider.TraeWork); got != 24*time.Hour { + t.Errorf("traework skew narrowed to %v, want >=24h", got) + } +} diff --git a/src/internal/traework/client.go b/src/internal/traework/client.go index c27dd8b..f535c89 100644 --- a/src/internal/traework/client.go +++ b/src/internal/traework/client.go @@ -195,7 +195,8 @@ func (c *Client) FetchModels(a *auth.Auth) ([]provider.ModelInfo, error) { ConfigInfoList []struct { ConfigName string `json:"config_name"` DisplayConfig struct { - DisplayName string `json:"display_name"` + DisplayName string `json:"display_name"` + IsCustomModel bool `json:"is_custom_model"` // 自定义模型(第三方代理)需额外授权 } `json:"display_config"` } `json:"config_info_list"` } @@ -210,6 +211,12 @@ func (c *Client) FetchModels(a *auth.Auth) ([]provider.ModelInfo, error) { if name == "" || seen[name] { continue } + // 跳过自定义模型(第三方代理,调用需额外授权,选中必失败): + // 部分模型上游不返回 is_custom_model,用 config_name 前缀兜底。 + if cfg.DisplayConfig.IsCustomModel || strings.HasPrefix(name, "custom_model_") { + log.Printf("traework skip custom model: %s", name) + continue + } seen[name] = true out = append(out, provider.ModelInfo{ID: name, Name: cfg.DisplayConfig.DisplayName}) } diff --git a/src/internal/traework/client_test.go b/src/internal/traework/client_test.go index e6fd130..322bbe3 100644 --- a/src/internal/traework/client_test.go +++ b/src/internal/traework/client_test.go @@ -119,3 +119,46 @@ func TestCheckinClaimRetriesRateLimit(t *testing.T) { t.Fatalf("claim calls=%d", calls.Load()) } } + +// TestFetchModelsSkipsCustomModels 校验 is_custom_model 与 custom_model_ 前缀 +// 两种形态的自定义模型都被过滤掉:这类模型(第三方代理)调用需额外授权, +// 出现在 /v1/models 里会让客户端选中后必然失败。 +func TestFetchModelsSkipsCustomModels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != EpModels { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"config_info_list":[ + {"config_name":"glm-5.2","display_config":{"display_name":"GLM 5.2"}}, + {"config_name":"kimi-k2","display_config":{"display_name":"Kimi K2","is_custom_model":true}}, + {"config_name":"custom_model_proxy-x","display_config":{"display_name":"Proxy X"}}, + {"config_name":"glm-5.2","display_config":{"display_name":"GLM 5.2 dup"}} + ]}`)) + })) + defer srv.Close() + + c := New() + c.AgentHost = srv.URL + c.HTTP = srv.Client() + models, err := c.FetchModels(&auth.Auth{AccessToken: "at"}) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + got := map[string]bool{} + for _, m := range models { + got[m.ID] = true + } + if !got["glm-5.2"] { + t.Errorf("normal model filtered out: %v", got) + } + if got["kimi-k2"] { + t.Errorf("is_custom_model=true not filtered: %v", got) + } + if got["custom_model_proxy-x"] { + t.Errorf("custom_model_ prefix not filtered: %v", got) + } + if len(models) != 1 { + t.Errorf("want 1 model (dedup + filter), got %d: %v", len(models), got) + } +} diff --git a/src/internal/upstream/client.go b/src/internal/upstream/client.go index 9dd5a54..0f120c9 100644 --- a/src/internal/upstream/client.go +++ b/src/internal/upstream/client.go @@ -89,6 +89,10 @@ type Client struct { // BillingHTTP 供账单/签到接口使用(短超时,慢网络下避免面板操作长时间假死)。 // 为 nil 时回退到 HTTP。 BillingHTTP *http.Client + // StreamHTTP 供 chat SSE 专用:不设总时长上限(Timeout=0), + // 否则 http.Client.Timeout 会把「读取响应体」一并计时,长回答超过阈值即被腰斩。 + // 首字节由 Transport.ResponseHeaderTimeout 约束。为 nil 时回退到 HTTP。 + StreamHTTP *http.Client ChatBaseCN string BillingBaseCN string @@ -102,9 +106,13 @@ func New() *Client { MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90 * time.Second, + // 首字节(响应头)超时:流式请求靠它防止建连后永久挂起, + // 与 traework 的 StreamHTTP 保持一致的 120s 口径。 + ResponseHeaderTimeout: 120 * time.Second, } return &Client{ HTTP: &http.Client{Timeout: 120 * time.Second, Transport: tr}, + StreamHTTP: &http.Client{Transport: tr}, // 无总时长上限,仅受首字节超时约束 BillingHTTP: &http.Client{Timeout: 30 * time.Second, Transport: tr}, ChatBaseCN: "https://copilot.tencent.com", BillingBaseCN: "https://www.codebuddy.cn", @@ -113,6 +121,14 @@ func New() *Client { } } +// streamClient 返回 chat SSE 专用客户端(无总时长上限)。 +func (c *Client) streamClient() *http.Client { + if c.StreamHTTP != nil { + return c.StreamHTTP + } + return c.HTTP +} + // billingClient 返回账单接口用的 HTTP 客户端。 func (c *Client) billingClient() *http.Client { if c.BillingHTTP != nil { @@ -229,7 +245,7 @@ func (c *Client) ChatStream(a *auth.Auth, body []byte) (rc io.ReadCloser, status return nil, 0, nil, err } ChatHeaders(req, a) - resp, err := c.HTTP.Do(req) + resp, err := c.streamClient().Do(req) if err != nil { log.Printf("chat_stream uid=%s: transport error: %v", a.UID, err) return nil, 0, nil, err diff --git a/src/internal/upstream/client_test.go b/src/internal/upstream/client_test.go index 96e01fa..136c0ae 100644 --- a/src/internal/upstream/client_test.go +++ b/src/internal/upstream/client_test.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/rockswang/workbuddy-wild/internal/auth" ) @@ -231,3 +232,33 @@ func TestRegionBases(t *testing.T) { t.Error("global bases wrong") } } + +// TestStreamClientHasNoTotalTimeout 校验 chat SSE 走的是无总时长上限的 client。 +// http.Client.Timeout 会把「读取响应体」一并计时,用带 Timeout 的 client 会让 +// 超过阈值的长回答在流中途被切断(客户端收到截断流)。 +func TestStreamClientHasNoTotalTimeout(t *testing.T) { + c := New() + if c.StreamHTTP == nil { + t.Fatal("StreamHTTP not configured") + } + if c.StreamHTTP.Timeout != 0 { + t.Errorf("StreamHTTP.Timeout=%v want 0 (no total cap)", c.StreamHTTP.Timeout) + } + if got := c.streamClient(); got != c.StreamHTTP { + t.Error("streamClient() should return StreamHTTP when set") + } + // 首字节仍需有超时约束,否则建连后可能永久挂起。 + tr, ok := c.StreamHTTP.Transport.(*http.Transport) + if !ok { + t.Fatalf("StreamHTTP.Transport type = %T", c.StreamHTTP.Transport) + } + if tr.ResponseHeaderTimeout != 120*time.Second { + t.Errorf("ResponseHeaderTimeout=%v want 120s", tr.ResponseHeaderTimeout) + } + + // 未设置 StreamHTTP 时回退到 HTTP,保证零值 Client 仍可用。 + fallback := &Client{HTTP: &http.Client{Timeout: time.Second}} + if got := fallback.streamClient(); got != fallback.HTTP { + t.Error("streamClient() should fall back to HTTP when StreamHTTP is nil") + } +}